Files
kiln/CLAUDE.md
2026-07-11 12:04:33 -05:00

21 KiB
Raw Permalink 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-tui/CLAUDE.md) for module-level changes. (kiln-ui is now a separate repo with its own CLAUDE.md.)
  2. Run the /simplify skill on changed code.
  3. Commit everything with a summary message.

Commands

cargo build                                # compile the workspace (kiln-core + kiln-tui; fetches the kiln-ui git dep)
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-end, and depends on an external, game-agnostic UI toolkit:

  • 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, game-agnostic and now a separate git repo (https://notpi.com/randrews/kiln-ui.git), consumed by kiln-tui as a git dependency. 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/render_overlay/CursorOverlay overlay scaffolding, and a hand-written modeless script code editor (code_editor::CodeEditor). It has no kiln-core dependency — the one kiln-core-coupled widget, the glyph picker, was left behind in kiln-tui (see below). The syntect (highlighting) and arboard (clipboard) deps make it desktop/terminal-only (not WASM), unlike its ratatui core.
  • kiln-tui — a terminal player + world editor built on ratatui 0.30 / crossterm, depending on kiln-core and the external kiln-ui. Takes a world-file path on the command line; plays a board (arrow keys) and edits it (Esc → menu → e). Owns the glyph picker (glyph_dialog::GlyphDialog<Ctx>) — the one game-coupled UI widget (needs Glyph/Rgba8 + the CP437 table), built on kiln-ui's public dialog scaffolding.

Root Cargo.toml: members = ["kiln-core", "kiln-tui"], with ratatui pinned in [workspace.dependencies]. The external kiln-ui pins the same ratatui 0.30.x so their public APIs (which exchange ratatui types) unify.

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

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 a single [grid]. The grid has a palette mapping each char to one kind of thing, and a char map given in one of three ways: content (a multi-line grid string), fill = "x" (the whole grid filled with one char), or sparse = [{ x, y, ch }, …] (an all-spaces grid with just those cells set). The grid holds every solid and most non-solids; each cell holds at most one authored thing. Alongside the grid, a board has three lightweight attributes:

  • floor — an optional [map] attribute (not a layer): floor = { generator = "grass" } (a procedural biome), floor = { tile = 176, fg = "#888", bg = "#444" } (one fixed glyph), or omitted (a blank/black floor). Drawn beneath everything, shown wherever the grid cell is empty.
  • [[triggers]] — invisible, non-solid, script-only objects: { x, y, script_name, name?, tags? }. No glyph, never solid or pushable; they exist only to run a script. At runtime they are ordinary objects in Board::objects.
  • [[decorations]] — non-solid (glyph, archetype) cells placed off the grid, drawn only where the grid cell is empty: { x, y, kind, tile?, fg?, bg? }. The editor does not author these; they exist so a save file can represent a non-solid that ended up sharing a cell with a runtime solid. A solid archetype is rejected.
# Two terser grid forms, equivalent to a full `content` grid:
[boards.room1.grid]
fill = "#"                                          # whole grid is wall
palette = { "#" = { kind = "wall" } }

[boards.room1.grid]
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(me, state) {                 # optional, run once at startup
    log(`player at ${state.player.x}, ${state.player.y}`);
    set_tile(2);                     # write: change my glyph
}
fn tick(me, state, dt) {
    if !me.waiting && !me.blocked(North) { move(North); }
}
fn bump(me, dir) { log(`bumped from ${dir}`); say("Ouch!"); }  # dir: the side it came from
"""

# 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
floor = { generator = "grass" }      # "grass" | "dirt" | "stone", or a fixed glyph, or omit

# The single grid: terrain, the player, objects and portals. A space is always a
# transparent empty cell, so the floor shows through.
[boards.room1.grid]
content = """
############################################################
#                     G               @              1     #
############################################################
"""
[boards.room1.grid.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 the grid
# 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" }

# Invisible script-only triggers (no glyph, never solid).
[[boards.room1.triggers]]
x = 40
y = 0
script_name = "tripwire"

Palette kind values: the meta-kinds empty / object / portal / player, or any archetype name directly (wall, crate, hcrate, vcrate, pusher_north|south|east|west, transporter_north|south|east|west, spinner_cw|spinner_ccw, gem, heart). (floor is no longer a grid kind — it is the board-level floor attribute.) For archetype/object kinds, tile/fg/bg are optional and fall back to that kind's default glyph. An unknown kind becomes a visible ErrorBlock (logged).

Colors are "#RRGGBB" hex strings. The player is placed by a kind = "player" char, which must appear exactly once in the grid (missing → (0,0) + error; multiple → first + error); that cell is transparent. tile accepts a single-character string (tile = " ") or an integer (tile = 35). The grid'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 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 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(me, state), tick(me, state, dt), bump(me, dir), enter(me, dir), and grab(me, state) functions — every hook receives me (this object, an ObjectInfo). bump's dir is the Direction the bump came from (pointing toward the bumper), fired when any solid — the player, another object, or a pushed crate — presses into this object. enter is the non-solid counterpart: it fires on a non-solid object when a solid relocates onto its cell (a player/object step, a pushed crate, a teleport, or a shift), with dir the side the entrant came from — exact (dir.opposite()) for a one-cell move/push, best-effort (dominant axis, via Direction::from_delta) for a teleport/shift that jumps an arbitrary distance. They read the world through state.player.* (e.g. state.player.x, .health, .keys.red), state.board.* (.width, .height, .can_push(x, y, dir), .passable(x, y), .get(id), .named(name), .tagged(tag)), and me.* (me.x, me.y, me.has_tag(s), me.blocked(dir), me.can_push(dir), me.waiting, me.queue), and write via host functions move(dir), set_tile(n), set_color(fg, bg), log(s), say(s), scroll(lines), send(target, fn [, arg]), set_tag(target, tag, present), teleport(id, x, y) (jump entity id to a cell; me.id = self, -1 = player), push(x, y, dir) (shove a chain at arbitrary coords), shift([[x, y], …]) (rotate a ring of cells one step), alter_gems(n) / alter_health(dh) / set_key(color, present) (change the player's gems / health / keys), and die() (remove the calling object). tick is invoked only when the object's output queue is empty — while actions from a prior tick are still pending (including a pacing Delay), the engine just drains the queue by dt without re-running tick, so a plain move() paces itself one step per drain and no if me.queue.length == 0 guard is needed. (Every other hook — bump/enter/grab/send/init — fires regardless of queued actions.) Writes don't take effect immediately within a hook: each is queued, and at most one move resolves per 250 ms per object. The exception is log(s), which is not queued — it writes to the game log the instant it is called, so a diagnostic line surfaces even when it sits behind a pending move/delay. But each object's ready actions are applied the moment its hook returns, before the next object runs — objects are processed in ascending id order each tick, so a later object observes the board after every lower-id object has already moved (the one exception is actions still stuck behind a Delay). When two objects contend for the same cell, lowest id wins, and the loser — whose hook ran later — can already see the winner there. The bumped object receives bump with the direction the bump came from; bump/enter/send reactions fire in a follow-up pass that settles fully within the same tick/try_move, bounded by a per-invocation guard that fires each (object, hook, args) at most once (so a bump/enter/send cycle can't loop forever). The full scripting reference lives in docs/script-api.md.

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)) or the per-board Registry (which also persists across transitions).

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 a fixed precedenceBoard::glyph_at at (x, y) returns the first that draws: the player; then an object on the cell (a solid object always, else a visible non-solid); then the grid cell (a solid always, else a non-transparent glyph); then a portal; then a decoration (only reachable because the grid cell was empty); then the floor glyph; then the canonical black Empty. There is one grid, plus the board-level floor attribute drawn beneath everything. A cell vacated by a pushed crate is left transparent (Glyph::transparent(), tile 0), revealing the floor with no movement-code changes. See board.rs / layer.rs / floor.rs.
  • One solid per cell — at most one solid entity (the player, a solid terrain archetype, or a solid object) may occupy a grid 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 single grid holds at most one authored char per cell, so a stacked solid is unrepresentable by construction; 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 and drops a conflicting object. Board::solid_at relies on this invariant.
  • The grid 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 a normal object drawn from Board::objects.

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_shift to despawn a displaced object), so references survive reordering. What's still missing: wiring live spawn/destroy into the ScriptHost (it compiles one script + builds one persistent Scope per object when constructed, so an object added after that has no script runtime, and a removed one keeps a stale — but harmless, no-op — scope 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).