20 KiB
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 coreRgba8, 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 everypubtype, 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 inupdateloops, 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:
- Update the relevant
CLAUDE.mdto reflect any new modules, types, or behaviors added during the session — the root file for project-wide changes, or the crate's ownCLAUDE.md(kiln-core/CLAUDE.md,kiln-tui/CLAUDE.md) for module-level changes. (kiln-uiis now a separate repo with its ownCLAUDE.md.) - Run the
/simplifyskill on changed code. - 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.tomlmap-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 contextCtxit mutates only through callbacks. Holds the dialog/text-field system (dialog::Dialog<Ctx>), the shareddim_area/render_overlay/CursorOverlayoverlay scaffolding, and a hand-written modeless script code editor (code_editor::CodeEditor). It has nokiln-coredependency — 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 onkiln-coreand the externalkiln-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 (needsGlyph/Rgba8+ the CP437 table), built onkiln-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 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 inBoard::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), 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. 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). 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/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/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
BehaviorandArchetypeare separate types —Archetypeis the named class of a thing (Wall,Empty,Object);Behavioris its runtime properties (solid,opaque,pushable). Adding a new property means adding a field toBehavior, not a match arm at every call site.- Draw order is a fixed precedence —
Board::glyph_atat(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 adecoration(only reachable because the grid cell was empty); then thefloorglyph; then the canonical blackEmpty. There is one grid, plus the board-levelfloorattribute 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. Seeboard.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_atreportsSolid::Playerfor 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 noplayerchar resolves) silently clears any solid terrain under it and drops a conflicting object.Board::solid_atrelies 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.ArchetypeisCopyso 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.): thematches inbehavior()/name()/default_glyph()are exhaustive (the compiler flags them), butTryFrom<&str>is not — forget its arm and the new name silently loads asErrorBlock. Script-backed archetypes (Builtin): adding one requires only (1) an entry in thebuiltins!macro invocation inarchetype.rsand (2) the.rhaiscript 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. Boardis the complete unit — grid, player, objects, and portals all live onBoard, 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 anArchetypewith 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: Playeris required and non-optional; a player-less board can't be represented (should becomeOption<Player>, or move the player out ofBoardto 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_movemutatesboard.playerdirectly; 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 fromBoard::objects.
Other not-yet-implemented threads
- Scripting growth — more event hooks beyond
init/tick/bump(e.g.on_touchwhen the player steps onto an object), a larger action/read vocabulary (more actions could carry atime_cost), and persisting script-local state across ticks (needscall_fn_rawso the per-objectScopeisn't rewound each call). - Runtime spawn/destroy of objects — objects now have stable
ObjectIds (Board::objectsis aBTreeMap<ObjectId, ObjectDef>with anext_object_idcounter;add_objectallocates,remove_objectdeletes — used byapply_shiftto despawn a displaced object), so references survive reordering. What's still missing: wiring live spawn/destroy into theScriptHost(it compiles one script + builds one persistentScopeper 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::loadloads all boards asRc<RefCell<Board>>,GameStateowns the wholeWorldand trackscurrent_board_name.try_movedetects stepping onto a portal and callsGameState::enter_board(target_map, target_entry), which switchescurrent_board_name, places the player at the named arrival portal, clears per-board transient state (speech/scroll), rebuilds theScriptHostfor the new board, re-runsinit(), and setsboard_transitionas a hook. kiln-tui detects the board change intry_move_with_transitionand plays theTransitionAnimationwipe. (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).