164 lines
21 KiB
Markdown
164 lines
21 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-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
|
||
|
||
```bash
|
||
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.md` — [`kiln-core/CLAUDE.md`](kiln-core/CLAUDE.md) and [`kiln-tui/CLAUDE.md`](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.
|
||
|
||
```toml
|
||
# 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
|
||
```
|
||
|
||
```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, 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`](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 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 a fixed precedence** — `Board::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 `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 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 `ObjectId`s (`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-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).
|