Files
kiln/CLAUDE.md
T
2026-06-23 01:08:01 -05:00

18 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project

kiln is a ZZT-inspired game-making system written in Rust (edition 2024) — a runtime and authoring environment where game worlds are plain-text files with embedded scripts. The model is ZZT (1991, Epic MegaGames): a DOS game that shipped with a built-in editor and a scripting language (ZZT-OOP), letting players create and share worlds. ZZT's playfield was 60×25 characters; each board was a self-contained screen with scripted objects, passages to adjacent boards, and ~50 built-in element types.

Tech-stack rationale (the "why"):

  • WASM compatibility is a first-class requirement — everything in the stack must compile to WASM. This is why scripting uses Rhai (pure Rust, sandboxed, no C deps, explicit WASM support) rather than Lua (which needs C FFI via mlua/rlua).
  • Maps are TOML + serde — human-readable, hand-editable, good Rust tooling; grid multi-line strings make the room shape visible, and embedded Rhai scripts fit TOML multi-line strings without escaping.
  • The engine (kiln-core) is UI-agnostic — no rendering/UI types leak in (e.g. log colors are core Rgba8, not a front-end type), so the same game data drives multiple front-ends. kiln-tui being added with zero engine changes is the proof.

Code style

  • Add /// rustdoc comments to every pub type, field, and function. The user reads rustdoc in their IDE to understand the code while making changes.
  • Add inline // comments inside non-trivial function bodies to explain the why of each logical step — especially in update loops, rendering math, and conversion logic.
  • Keep comments accurate: update them when the code they describe changes.

Finishing an epic

When the user says "finish the epic", do all of the following in order:

  1. Update the relevant CLAUDE.md to reflect any new modules, types, or behaviors added during the session — the root file for project-wide changes, or the crate's own CLAUDE.md (kiln-core/CLAUDE.md, kiln-ui/CLAUDE.md, kiln-tui/CLAUDE.md) for module-level changes.
  2. Run the /simplify skill on changed code.
  3. Commit everything with a summary message.

Commands

cargo build                                # compile the workspace (kiln-core + kiln-ui + kiln-tui)
cargo run -p kiln-tui -- maps/start.toml   # play/edit a world in the terminal
cargo test                                 # run all tests
cargo test <name>                          # run a single test by name (substring match)
cargo clippy                               # lint
cargo fmt                                  # format

Architecture

kiln is a Cargo workspace that separates the engine from its front-ends so the same game data can be driven by different UIs:

  • kiln-core — the engine: all core game types (Board, Glyph, Archetype, GameState, …) plus .toml map-file load/save. No rendering or UI; every front-end depends on it.
  • kiln-ui — reusable terminal UI widgets on ratatui, mostly game-agnostic. Each widget owns its presentation + input handling and is generic over a host context Ctx it mutates only through callbacks. Holds the dialog/text-field system (dialog::Dialog<Ctx>), the shared dim_area overlay helper, a hand-written modeless script code editor (code_editor::CodeEditor), and the glyph picker (glyph_dialog::GlyphDialog<Ctx>). The glyph picker is the one widget that depends on kiln-core (for Glyph/Rgba8 + the CP437 table); everything else is game-agnostic. The syntect (highlighting) and arboard (clipboard) deps make this crate desktop/terminal-only (not WASM), unlike its ratatui core.
  • kiln-tui — a terminal player + world editor built on ratatui 0.30 / crossterm, depending on both kiln-core and kiln-ui. Takes a world-file path on the command line; plays a board (arrow keys) and edits it (Esc → menu → e).

Root Cargo.toml: members = ["kiln-core", "kiln-tui", "kiln-ui"], with ratatui pinned in [workspace.dependencies] so kiln-ui and kiln-tui share one version (their public APIs exchange ratatui types).

Module-by-module reference for each crate lives in that crate's own CLAUDE.mdkiln-core/CLAUDE.md, kiln-ui/CLAUDE.md, and kiln-tui/CLAUDE.md — which Claude Code loads automatically when you work in that crate.

World file format (maps/*.toml)

Each .toml file is a world: a named collection of boards plus a shared script pool. The [world] header names the world and designates the starting board. Scripts live once at the top level and are referenced by name from any board's objects. Each board lives under [boards.NAME.*] where NAME is the board's identifier string.

Keep this example in sync with world.rs / map_file.rs whenever the format changes.

A board is a [map] header plus an ordered array of [[layers]] (bottom→top). Each layer has a palette mapping each char to one kind of thing, and a grid given in one of three ways: content (a multi-line grid string), fill = "x" (the whole grid filled with one char — e.g. a uniform floor), or sparse = [{ x, y, ch }, …] (an all-spaces grid with just those cells set — e.g. a layer of a few objects). To put more than one thing on a cell (e.g. a non-solid object above a wall), put them on different layers — drawing follows layer order.

# Two terser layer forms, equivalent to a full `content` grid:
[[boards.room1.layers]]
fill = "g"                                          # whole layer is grass floor
palette = { "g" = { kind = "floor", generator = "grass" } }

[[boards.room1.layers]]
sparse = [ { x = 5, y = 3, ch = "C" }, { x = 9, y = 7, ch = "C" } ]  # two chests
palette = { "C" = { kind = "object", script_name = "chest" } }       # spaces are implicitly empty
[world]
name = "My World"
start = "room1"          # key of the starting board

# Named Rhai scripts — shared across all boards. Objects reference a script
# by script_name = "key". Scripts do NOT live on individual boards.
[scripts]
greeter = """
fn init() {                          # optional, run once at startup
    log(`player at ${Board.player_x}, ${Board.player_y}`);
    set_tile(2);                     # write: change my glyph
}
fn tick(dt) {
    if Queue.length() == 0 && !blocked(North) { move(North); }
}
fn bump(id) { log(`bumped by ${id}`); say("Ouch!"); }
"""

# Each board is a named subtable under [boards]. The board key ("room1") is
# used for cross-board references (e.g. portals) and becomes current_board_name.
# NOTE: there is no `player_start` — the player is placed by a kind="player" char.
[boards.room1.map]
name = "Room One"
width = 60
height = 25

# Layer 0 (bottom): the visual floor. Generators roll a fresh textured glyph per
# cell; a fixed-glyph form (tile/fg/bg, no generator) is also allowed.
[[boards.room1.layers]]
content = """
...60×25 grid of 'g'/'d'...
"""
[boards.room1.layers.palette]
"g" = { kind = "floor", generator = "grass" }    # "grass" | "dirt" | "stone"
"d" = { kind = "floor", generator = "dirt" }
"." = { kind = "floor", tile = ".", fg = "#222", bg = "#000" }   # fixed floor glyph

# Layer 1: terrain, the player, objects and portals. A space is always a
# transparent empty cell, so the floor below shows through.
[[boards.room1.layers]]
content = """
############################################################
#                     G               @              1     #
############################################################
"""
[boards.room1.layers.palette]
# A space is always a transparent empty cell — it is never a palette key.
"#" = { kind = "wall",  tile = 35,  fg = "#808080", bg = "#606060" }
"o" = { kind = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" }  # solid + pushable (any dir)
"-" = { kind = "hcrate" }                                         # pushable east/west only
"|" = { kind = "vcrate" }                                         # pushable north/south only
">" = { kind = "pusher_east" }
"@" = { kind = "player" }                                         # exactly one across all layers
# An object: spawned once per occurrence of its char (uppercase by convention).
# If it has a `name`, only the first instance keeps it (names are board-unique).
"G" = { kind = "object", tile = "#", fg = "#aa3333", bg = "#000000",
        solid = false, name = "greeter", script_name = "greeter" }
# A portal: its char must appear exactly once.
"1" = { kind = "portal", name = "east_door", target_map = "room2", target_entry = "west_door" }

Palette kind values: the meta-kinds empty / floor / object / portal / player, or any archetype name directly (wall, crate, hcrate, vcrate, pusher_north|south|east|west, spinner_cw|spinner_ccw, gem). For archetype/floor/object kinds, tile/fg/bg are optional and fall back to that kind's default glyph. An unknown kind becomes a visible ErrorBlock (logged). A floor entry uses generator for a procedural texture or tile/fg/bg for a fixed glyph.

Colors are "#RRGGBB" hex strings. The player is placed by a kind = "player" char, which must appear exactly once across all layers (missing → (0,0) + error; multiple → first + error); that cell is transparent. tile accepts a single-character string (tile = " ") or an integer (tile = 35). Each layer's content multi-line string has its leading newline trimmed by TOML and must match width × height (the only hard error); the fill/sparse forms are always exactly sized. An unknown kind produces an ErrorBlock and a logged warning. An object is spawned once per occurrence of its char (uppercase by convention) in reading order. Loading is best-effort and nonfatal (only a layer grid-dimension mismatch is a hard error): each problem is recorded on Board (see is_valid() / load_errors()). The player wins its cell: it silently clears solid terrain under it (on any layer) and drops any conflicting solid object. Script source lives in the world-level [scripts] table; objects reference a script by script_name. Scripts may define optional init(), tick(dt), bump(id), and grab() functions; they read the world through Board.* (e.g. Board.player_x), check blocked(dir) -> bool, can_push(x, y, dir) -> bool, can_shift(x, y, dir) -> bool, and passable(x, y) -> bool, inspect/empty their pending actions via Queue.length()/Queue.clear(), and write via host functions move(dir), set_tile(n), log(s), say(s), scroll(lines), push(x, y, dir) (shove a chain at arbitrary coords), swap([[src_x, src_y, dst_x, dst_y], …]) (move several solids simultaneously), add_gems(n) (change the player's gem count), and die() (remove the calling object). Writes don't take effect immediately: each is queued and at most one move resolves per 250 ms per object. When two objects move into the same cell, lowest id wins and the bumped object receives bump(id).

Script state across board transitions: Rhai Scope local variables reset when the ScriptHost is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as Rc<RefCell<Board>> in World::boards. Scripts that need to persist information across transitions should encode it in board data (e.g. set_tag(Me.id, "visited", true)).

Key design decisions

  • Behavior and Archetype are separate typesArchetype is the named class of a thing (Wall, Empty, Object); Behavior is its runtime properties (solid, opaque, pushable). Adding a new property means adding a field to Behavior, not a match arm at every call site.
  • Draw order is layer orderBoard::glyph_at walks Board::layers top-down and returns the first thing that draws (a solid object always; a visible non-solid object; a portal; a solid terrain cell; a non-transparent terrain cell). There is no separate floor field: a "floor" is an Empty cell with a visible glyph on a lower layer, so a non-solid object on a higher layer can render above a solid one. A cell vacated by a pushed crate is left transparent (Glyph::transparent(), tile 0), revealing the layer beneath with no movement-code changes. See board.rs / layer.rs.
  • One solid per cell (across all layers) — at most one solid entity (the player, a solid terrain archetype on any layer, or a solid object) may occupy a cell. The player is a first-class solid: Board::solid_at reports Solid::Player for the player's cell (checked first), it blocks movers, and it is pushable in any direction — a push chain that reaches the player slides the player along (and is rejected if the player has nowhere to go, e.g. against a wall). The map loader enforces the invariant: a second solid stacked on a cell is dropped (recorded as a load error); the player wins its cell — its resolved position (including the (0, 0) fallback when no player char resolves) silently clears any solid terrain under it (any layer) and drops a conflicting object. Board::solid_at relies on this invariant.
  • Each layer is Vec<(Glyph, Archetype)> — every cell owns its visual and behavioral class directly; there is no per-board element palette or integer indirection. Archetype is Copy so this is efficient. A transparent cell (glyph tile 0) draws nothing; solidity is read from the archetype regardless of transparency.
  • Archetypes are referenced by name in map files — so variants can be reordered or extended without breaking saved games. Terrain archetypes (Wall, Crate, etc.): the matches in behavior()/name()/default_glyph() are exhaustive (the compiler flags them), but TryFrom<&str> is not — forget its arm and the new name silently loads as ErrorBlock. Script-backed archetypes (Builtin): adding one requires only (1) an entry in the builtins! macro invocation in archetype.rs and (2) the .rhai script file — TryFrom<&str>, behavior(), name(), default_glyph(), and the expansion pass all derive from the macro output automatically. In both cases, also update the map-format example.
  • Board is the complete unit — grid, player, objects, and portals all live on Board, matching how ZZT treats a "board". No separate wrapper struct.
  • Glyph (visual) and Archetype (behavior) are decoupled — each cell has its own Glyph (so colors can vary per-cell, e.g. fire flickering) while sharing an Archetype with other cells of the same type.
  • World loading happens in main() before the terminal is initialized, so load errors print to a normal screen and board dimensions are known before the game loop starts.

Future direction: the player may become an object

The long-term goal is for the "player" to be a board object that happens to respond to arrow-key events — not a hardcoded special entity — and for some boards to have no player at all (a cutscene/menu/puzzle that handles input differently). ZZT hardcoded the player as a special element and authors had to hack around it; avoiding that is deliberate.

Today's baked-in assumptions that will need to change (don't make Board.player more central in the meantime — e.g. don't add methods that assume player presence):

  • Board.player: Player is required and non-optional; a player-less board can't be represented (should become Option<Player>, or move the player out of Board to the engine layer).
  • A kind = "player" cell is effectively required (exactly one); player spawning should eventually move into the object/script system rather than being a special palette kind.
  • GameState::try_move mutates board.player directly; once the player is script-driven, movement should go through event dispatch (e.g. dispatch_event(ArrowKey(dir))) rather than a dedicated method.
  • The player is rendered as a hardcoded overlay (Glyph::player()); it should become part of the normal object layer.

Other not-yet-implemented threads

  • Scripting growth — more event hooks beyond init/tick/bump (e.g. on_touch when the player steps onto an object), a larger action/read vocabulary (more actions could carry a time_cost), and persisting script-local state across ticks (needs call_fn_raw so the per-object Scope isn't rewound each call).
  • Runtime spawn/destroy of objects — objects now have stable ObjectIds (Board::objects is a BTreeMap<ObjectId, ObjectDef> with a next_object_id counter; add_object allocates, remove_object deletes — used by apply_swap to despawn a displaced object), so references survive reordering. What's still missing: wiring live spawn/destroy into the ScriptHost (it builds one ObjectRuntime per object once in GameState::new, so an object added after that has no script runtime, and a removed one keeps a stale — but harmless, no-op — runtime until the host is rebuilt).
  • Portals & multi-boarddone: world::load loads all boards as Rc<RefCell<Board>>, GameState owns the whole World and tracks current_board_name. try_move detects stepping onto a portal and calls GameState::enter_board(target_map, target_entry), which switches current_board_name, places the player at the named arrival portal, clears per-board transient state (speech/scroll), rebuilds the ScriptHost for the new board, re-runs init(), and sets board_transition as a hook. kiln-tui detects the board change in try_move_with_transition and plays the TransitionAnimation wipe. (Errors — missing target board or entry — are logged, not fatal.)
  • Load-error surfacingBoard::load_errors() is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup).