164 lines
18 KiB
Markdown
164 lines
18 KiB
Markdown
# 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 <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.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() { # 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`, `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()`, `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), `alter_health(dh)` (change the player's health, clamped to `[0, max_health]`), 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 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<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 `ObjectId`s (`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-board** — **done**: `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 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).
|