# 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 ```bash 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 # 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`), the shared `dim_area` overlay helper, a hand-written modeless script code editor (`code_editor::CodeEditor`), and the **glyph picker** (`glyph_dialog::GlyphDialog`). 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.md` — [`kiln-core/CLAUDE.md`](kiln-core/CLAUDE.md), [`kiln-ui/CLAUDE.md`](kiln-ui/CLAUDE.md), and [`kiln-tui/CLAUDE.md`](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. ```toml # 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 ``` ```toml [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, state, 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`, `heart`). 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(me, state)`, `tick(me, state, dt)`, `bump(me, state, id)`, and `grab(me, state)` functions — every hook receives `me` (this object, an `ObjectInfo`) and `state` (the world). 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(x, y)`, `push(x, y, dir)` (shove a chain at arbitrary coords), `shift([[x, y], …])` (rotate a ring of cells one step), `add_gems(n)` / `alter_health(dh)` / `set_key(color, present)` (change the player's gems / health / keys), 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`. The full scripting reference lives in [`docs/script-api.md`](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>` 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 types** — `Archetype` 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 order** — `Board::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 `match`es 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`, 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 `ObjectId`s (`Board::objects` is a `BTreeMap` 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-board** — **done**: `world::load` loads all boards as `Rc>`, `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 surfacing** — `Board::load_errors()` is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup).