24 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). The goal is a system where players can create games using a scripting language, similar to the classic DOS game ZZT. It uses Rhai for scripting (WASM-compatible, sandboxed, pure Rust).
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
CLAUDE.mdto reflect any new modules, types, or behaviors added during the session. - Update
ARCHITECTURE.mdto reflect the same. - Run the
/simplifyskill on changed code. - Commit everything with a summary message.
Commands
cargo build # compile the workspace (kiln-core + kiln-tui)
cargo run -p kiln-tui -- maps/start.toml # play a board 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.tomlmap-file load/save. No rendering or UI; every front-end depends on it.kiln-tui— a terminal player (no editor yet) built on ratatui 0.30 / crossterm. Takes a map-file path on the command line and lets you walk the player around the board with the arrow keys.kiln-egui— the original eframe/egui desktop app (player + editor). Still in the tree but no longer a workspace member: it was removed from the rootCargo.tomlmembersand its files were left untouched, so it is not built at the root and is not guaranteed to compile against the current engine. Its notes below are retained for when it is revived.
Root Cargo.toml: members = ["kiln-core", "kiln-tui"].
kiln-core modules
kiln-core/src/game.rs — all core game types:
Glyph(Copy,Eq,Hash) — per-cell visual:tile: u32(tilesheet index),fg/bg: Color32.Glyph::player()is aconst fn; all other glyphs come from the map file. DerivesHashso boards can deduplicate glyphs into a palette.FontSpec— optional per-board bitmap font override:path: String,tile_w: u32,tile_h: u32. WhenNone, the app default font is used.Behavior— plain data struct of runtime behavioral properties:solid: bool(blocks/participates in movement — inverse of the oldpassable),opaque: bool,pushable: bool(all archetypesfalsefor now; the push mechanic is future work). Returned byArchetype::behavior(); new properties added here require no match arms elsewhere.Solid<'a>— the single solid occupant of a cell, returned byBoard::solid_at:Cell(Archetype)(a solid grid archetype like a wall) orObject(&ObjectDef)(a solid object). At most one solid may occupy a cell — enforced at load time.Archetype(Copy,PartialEq) — enum of named element types:Empty,Wall,ErrorBlock. Each variant providesbehavior(),name()(used in map files), anddefault_glyph()(used by the editor when stamping a cell).ErrorBlockis a sentinel for unknown archetype names — renders as yellow?on red.ALL_ARCHETYPES: &[Archetype]— ordered list of valid editor choices (excludesErrorBlock).Board— the complete game unit (ZZT-style "board"):width,height,cells: Vec<(Glyph, Archetype)>(row-major; each cell owns its visual and behavioral class directly),player: Player,objects: Vec<ObjectDef>,portals: Vec<PortalDef>,font: Option<FontSpec>.cellsispub(crate).solid_at(x, y) -> Option<Solid>returns the cell's single solid occupant (object checked before grid archetype);is_passable(x, y)is the convenience inverse (solid_at(...).is_none()).Player—x: i32, y: i32ObjectDef— scripted object placed on the board:x,y,glyph: Glyph,solid: bool,opaque: bool,pushable: bool,script_name: Option<String>.solidandopaquedefaulttrue,pushabledefaultsfalsein map files. Itsinit()/tick(dt)hooks are run by the scripting runtime (seescript.rs).PortalDef— parsed from map files, stored on Board; not yet runtime-wiredGameState— holdsboard: Rc<RefCell<Board>>, the messagelog: Vec<LogLine>, and aScriptHost. The board sits behindRc<RefCell<…>>as a sibling of theScriptHostso script host functions can hold a shared handle to it without aliasing the borrow that's running the engine. Front-ends/logic reach the board throughboard() -> Ref<Board>/board_mut() -> RefMut<Board>(there is nopub boardfield).try_movemoves the player;run_init()runs objectinit()hooks once at startup;tick(dt)runs objecttick(dt)hooks every frame. After each script batch,apply_commands()drains the script command queue:Log/Error→log,SetTile→ the source object's glyph,Move(dir)→move_object(bounds +is_passable). This deferred apply (writes after the batch) is what keeps script execution borrow-safe.
kiln-core/src/log.rs — styled log messages:
LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> }andLogLine { spans: Vec<LogSpan> }— a UI-agnostic styled message (colors are coreRgba8, not a front-end type).LogLine::raw(), a chainablepush(), andappend()build messages; each front-end converts aLogLineto its own styled text at render time.
kiln-core/src/script.rs — Rhai scripting runtime:
ScriptHost— owns the RhaiEngine, the compiled scripts referenced by a board's objects (compiled once per name), a per-object persistentScope, and a shared command queue. Built withScriptHost::new(&Rc<RefCell<Board>>)(registers the API, compiles scripts, reports compile/unknown-script failures asGameCommand::Error; runs nothing).- Lifecycle hooks per object:
init()(zero-arg) andtick(dt)(elapsed seconds asf64), both optional — detected viaAST::iter_functions()by name and arity. Driven byrun_init()/run_tick(dt); runtime errors becomeErrorcommands, not fatal. - Reads (direct): read getters (
player_x,player_y,width,height) are registered directly onRc<RefCell<Board>>(theBoardRefalias, exposed to Rhai under the type nameBoard) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constantBoard, so scripts writeBoard.player_xetc. Each getter briefly borrows the sharedBoard. - Writes (command queue): host fns
move(dir),set_tile(n),log(s)push aCommand { source, kind: GameCommand }(GameCommand=Move(Direction)/SetTile(u32)/Log(LogLine)/Error(String)) into a sharedRc<RefCell<Vec<Command>>>, drained byGameState::take_commandsand applied after the batch. This is how scripts mutate without a&mut GameStateborrow. - Sender identity:
source(which object issued a command) rides the per-call tag —runcallscall_fn_with_options(...with_tag(object_index)...)and the host fns read it viaNativeCallContext::tag(). Scripts writemove(North)without naming themselves;North/South/East/WestareDirectionconstants in scope, andimpl From<Direction> for (i32,i32)gives the delta. GameState(hence theEngine/Scope) is single-threaded / notSend; fine for kiln-tui.- TODO in-code:
Command.source(andObjectRuntime.object_index) are array indices intoBoard::objects— a stopgap that breaks under object spawn/destroy/reorder; should become a monotonically-increasing unique object id with objects keyed by it.
kiln-core/src/map_file.rs — map file loading:
MapFileand friends — serdeDeserializetypes for TOML map filesTileIndex—#[serde(untagged)]enum accepting eitherNum(u32)orChr(char); lets map files usetile = " "(char) ortile = 32(integer) interchangeablyFontHeader— deserializes the optional[font]section (path,tile_w,tile_h)impl From<MapFile> for Board— converts a parsed file into a ready-to-useBoard; unknown archetype names produceErrorBlockpub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>>— reads and converts a.tomlmap filepub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>>— serializesBoardback to TOML and writes to disk
kiln-tui modules
The terminal player. Renders the board as text: each Glyph.tile index is reinterpreted as a character (the default kiln font is CP437, where the tile index equals the code point) and drawn with the glyph's RGB colors. No bitmap font or UV mapping is involved.
kiln-tui/src/main.rs — entry point and play loop:
- Parses a single positional arg (the map path); prints usage and exits non-zero if missing. Loads the board via
kiln_core::map_file::loadbefore touching the terminal so load errors print to a normal screen. ratatui::init()→ detectTerminalCaps→ push Kitty flags when supported →runloop → pop Kitty flags →ratatui::restore().runis a ~30 FPS real-time loop: itevent::polls only until the next frame deadline (so input stays responsive) and otherwise wakes to advance the game by the real elapseddtviaGameState::tick. It acts onPressandRepeatkey events (so holding a key moves continuously) and ignoresRelease(which the Kitty protocol also emits, and which would otherwise double-fire each move). Arrow keys move the player viaGameState::try_move;ltoggles the log panel;q/Escquit. Mouse capture is enabled so the log panel scrolls (wheel) and resizes (drag the divider).game.run_init()runs objectinit()hooks after the board is loaded and before the loop.drawwraps the board in a borderedBlocktitled with the board name and controls. When the log panel is closed, the latest log line is previewed in the bottom-left border after a[l]og:label; when open, a bottom panel lists the log newest-first (render::logline_to_lineconverts eachLogLine).Ui { log_open, log_height, scroll }— view-only panel state kept in the front-end (not onGameState).
kiln-tui/src/cp437.rs — CP437 → Unicode mapping:
CP437: [char; 256]— full code-page-437 table (graphic glyphs for 0x00–0x1F, box-drawing for 0xB0–0xDF, etc.).tile_to_char(tile: u32) -> char— indexes the table fortile < 256; falls back tochar::from_u32then a blank space. Unit-tested.
kiln-tui/src/render.rs — board → ratatui buffer:
rgba8_to_color(Rgba8) -> Color::Rgb— lossless RGB bridge (alpha dropped); truecolor terminals show exact colors, 256-color ones approximate.BoardWidget<'a>— aratatui::widgets::Widgetthat draws the board. Per axis,axis(board_len, view, player) -> (offset, pad, count): a board smaller than the viewport is centered with screen padding, a larger one scrolls to keep the player on screen with no empty margins. Each cell resolves glyph priority player > object > grid floor, then writes char + fg/bg into the buffer.
kiln-tui/src/term.rs — terminal capability detection and Kitty setup:
TerminalCaps { keyboard_enhancement: bool, truecolor: bool }— detected once at startup.keyboard_enhancementis a real query/response (supports_keyboard_enhancement());truecolorreads$COLORTERM. Intended to be handed to scripts so they can pick bindings the terminal actually supports.summary_line() -> Line— styled one-line badge for the bottom border; whentruecolor, the word "truecolor" is drawn with each letter a different rainbow color (hue_to_rgb, an HSV→RGB helper), else a plain "256-color".push_kitty_flags()/pop_kitty_flags()— enable/disable the Kitty keyboard protocol (DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES | REPORT_ALTERNATE_KEYS | REPORT_ALL_KEYS_AS_ESCAPE_CODES); the last flag makes modifier-only keypresses observable. Pushed only when supported, popped before restore. EnablingREPORT_EVENT_TYPESis why the input loop must filter outReleaseevents.
kiln-egui modules (no longer a workspace member)
kiln-egui/src/font.rs — bitmap font loading and UV mapping:
BitmapFont— wraps an eguiTextureHandle(preprocessed to opaque-white / transparent) plus tile dimensions. The top-left pixel of the source PNG defines the "background" color; those pixels becomeTRANSPARENT, all others becomeWHITE. At render time,painter.image(…, tile_uv, fg_color)tints white pixels tofgand transparent pixels reveal thebgfill behind.BitmapFont::load(ctx, path, tile_w, tile_h)— loads from diskBitmapFont::from_bytes(ctx, bytes, tile_w, tile_h, label)— loads from embedded bytesBitmapFont::create_placeholder(ctx)— 16×16 grid of 8×8 procedural tiles (bit-pattern bars), used as fallback when no font file is presentBitmapFont::tile_uv(tile)— returns a UVRectfor the given tile index (left-to-right, top-to-bottom). Unit-tested viacompute_tile_uv.BitmapFont::cell_size(zoom)— pixel size (Vec2) of one rendered cell at the given integer zoom; centralizes thetile_w * zoommath used across rendering and hit-testingBitmapFont::tile_cols(),tile_count(),img_w(),img_h()— geometry helpers used by the glyph picker and font dialog
kiln-egui/src/font_dialog.rs — font picker dialog:
FontDialogState— in-progress edit:path: String,tile_w/tile_h: u32,preview: Option<BitmapFont>FontDialogState::from_spec(spec)— initializes from an existingFontSpecor defaults (8×16, empty path)show(ctx, open, state, font_spec)— floating dialog with file browser (rfd::FileDialog), tile dimensionDragValuecontrols, scrollable tilesheet preview with grid overlay, and Apply / Use default buttons. Uses ashould_closeflag to avoid the egui.open()borrow conflict.
kiln-egui/src/main.rs — app entry point and frame loop:
AppModeenum (Play|Edit) — gates arrow-key input; toggles the side panel and viewport modeAppholdsGameState,AppMode,EditorState,default_font: BitmapFont, andboard_font: Option<BitmapFont>App::new(board, egui_ctx)— loadsassets/vga-font-8x16.pngas the default font (falls back tocreate_placeholder); loads per-board font fromboard.fontif presentApp::updateis a thin sequence of phase methods:handle_input(table-driven arrow keys, Play only) →menu_bar(File Save/Save As viasave_to/save_as, Exit, mode toggle) →try_show_script_editor(returnstrueto take over the frame) → editor side panel + font reload →show_board→handle_board_click→show_glyph_pickers→ object-glyph writebackApp::active_font()— resolves the per-board font or the default; used wherever an exclusive borrow ofselfis not also neededApp::show_board(&self) -> Option<(usize, usize)>— draws the viewport and returns the clicked cell (Edit mode); the click is dispatched tohandle_board_click(&mut self, cx, cy)aftershow_boardreturns, so the&muthandler never conflicts with the font borrow- Font change detection:
font_spec_beforeis snapshotted beforeshow_editor_panel; if changed,apply_font_specreloadsboard_font - Borrow split: at the editor-panel and glyph-picker call sites,
self.board_font.as_ref().unwrap_or(&self.default_font)is used inline (notactive_font()) so the font borrow stays disjoint from the&mut self.editor/&mut boardborrows - Play mode: arrow keys move player;
render::board_origincenters or player-tracks the viewport - Edit mode:
ScrollArea::both()wraps the board; click-to-paint stamps(editor.palette.glyph, editor.palette.selected); callseditor::show_editor_panelandglyph_picker::show
kiln-egui/src/render.rs — cell rendering and drawing primitives:
- Window sizing constants (
DEFAULT_WINDOW_W = 840,DEFAULT_WINDOW_H = 524,MIN_WINDOW_W/H); no fixedCELL_W/H— cell size comes from the activeBitmapFont rgba8_to_color32(c)/color32_to_rgba8(c)— inverse bridges between corecolor::Rgba8and eguiColor32paint_glyph(painter, rect, glyph, font)—rect_filledwithglyph.bg, thenpainter.imagetinted byglyph.fgglyph_preview_button(ui, glyph, font) -> Response— allocates a one-cell swatch (unzoomed), paintsglyph, returns the click response; used by the Palette and Objects tabsdraw_glyph(painter, origin, x, y, glyph, font, zoom)— sizes cell viafont.cell_size(zoom), delegates topaint_glyphdraw_board(painter, origin, board, font, zoom)— draws all cells then player overlaydraw_object_overlays(painter, origin, board, font, selected, zoom)— highlights all object cells in the Objects editor tab; the selected object gets a brighter borderboard_origin(available, board_w, board_h, player, font, zoom)— centers board or clamps to player with no empty spacepos_to_cell(origin, pos, tile_w, tile_h) -> (i32, i32)— pixel → cell coordinates; negatives signal out-of-bounds
kiln-egui/src/editor.rs — editor state and side panel:
EditorTabenum (Palette|Objects|Board|Scripts|World) — which tab is activeEditorState—tab: EditorTabplus four concern-grouped sub-structs so each tab takes only the borrow it needs:palette: PaletteState—selected: Archetype,glyph: Glyph,picker_open: bool(selecting a new archetype resetsglyphto its default)font_dialog: FontDialog—open: bool,state: FontDialogStateobjects: ObjectEdit—selected: Option<usize>,picker_open: bool,editing_glyph: Glyph,placing: boolscripts: ScriptEdit—editing: Option<String>,content: String,new_name: String
show_editor_panel(ctx, editor, board, active_font)— resizable right-side panel (default 200 px); renders the tab bar then dispatches to a per-tab function (palette_tab,objects_tab,board_tab,scripts_tab; World is empty), passing the relevant sub-struct(s). Palette shows the archetype list and glyph preview button; Board shows the font path, "Font…" button, and zoom slider; Objects lists the selected object's glyph preview, Passable/Opaque checkboxes, and script combobox; Scripts creates/edits named scripts
kiln-egui/src/glyph_picker.rs — floating glyph picker dialog:
show(ctx, open, glyph, board_cells, font)— takesopen: &mut boolandglyph: &mut Glyphdirectly; no dependency onEditorState- Three sections: board palette (unique glyphs deduped via
HashSet<Glyph>, click to select), FG/BG color pickers, scrollable tile grid (all tiles in the active font attile_w × tile_hper cell)
Map file format (maps/*.toml)
XPM-inspired: a [palette] maps single characters to (Glyph, Archetype) definitions; [grid] content is a TOML multi-line string where each character indexes the palette.
Keep this example in sync with map_file.rs whenever the format changes.
[map]
name = "Room Name"
width = 60
height = 25
player_start = [30, 12]
# Optional: override the default font for this board.
[font]
path = "assets/my_font.png"
tile_w = 8
tile_h = 16
[palette]
" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
"#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }
[grid]
content = """
############################################################
# G #
############################################################
"""
[[objects]] # optional
# Placement: either `x`/`y`, OR a `palette` char that appears in the grid.
# Convention: object palette chars are uppercase letters (here `G`). The char
# must appear exactly once in the grid, be unique across objects, and not be a
# [palette] key; the cell under it loads as Empty.
palette = "G"
tile = "#"
fg = "#aa3333"
bg = "#000000"
solid = false # blocks movement? defaults true. (pushable defaults false)
script_name = "greeter" # references a key in [scripts]
[[portals]] # optional; parsed but not yet runtime-wired
x = 59
y = 12
target_map = "cave"
target_entry = "west_door"
# Named Rhai scripts, referenced by objects via `script_name`.
[scripts]
greeter = """
fn init() { # optional, run once at startup
log(`player at ${Board.player_x}, ${Board.player_y}`); # read the world
set_tile(2); # write: change my glyph
}
fn tick(dt) { move(North); } # optional, run every frame; dt = elapsed seconds
"""
Colors are "#RRGGBB" hex strings. player_start is a header field — the player is not a board cell. The tile field accepts either a single-character string (tile = " ") or an integer (tile = 35); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by str::lines(). Unknown archetype names produce an ErrorBlock cell and a logged warning, as does any grid character that is neither a [palette] key nor an object's palette char. Objects may be placed by x/y or by a single grid palette char (uppercase by convention); placement and one-solid-per-cell violations are logged and the offending object is dropped (the rest of the board still loads). Script source lives in the [scripts] table (name → Rhai source); objects reference a script by script_name. Scripts may define optional init()/tick(dt) functions; they read the world through Board.* (e.g. Board.player_x) and write via host functions move(dir) (dir ∈ North/South/East/West), set_tile(n), and log(s).
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.- One solid per cell — at most one solid entity (a solid grid archetype or a solid object) may occupy a cell. The map loader enforces this: a solid object landing on an already-solid cell is logged and dropped.
Board::solid_atrelies on this invariant. cells: Vec<(Glyph, Archetype)>— each cell owns its visual and behavioral class directly; there is no per-board element palette or integer indirection.ArchetypeisCopyso this is efficient.- Archetypes are referenced by name in map files — so
ALL_ARCHETYPEScan be reordered or extended without breaking saved games. 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. - File loading happens in
main()before the window is created, so board dimensions are available for window sizing.
eframe runs on its own event loop thread; do not assume single-threaded execution.