Files
kiln/CLAUDE.md
T
2026-06-05 00:30:14 -05:00

22 KiB
Raw Blame History

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 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 CLAUDE.md to reflect any new modules, types, or behaviors added during the session.
  2. Update ARCHITECTURE.md to reflect the same.
  3. Run the /simplify skill on changed code.
  4. 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 .toml map-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 root Cargo.toml members and 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 a const fn; all other glyphs come from the map file. Derives Hash so boards can deduplicate glyphs into a palette.
  • FontSpec — optional per-board bitmap font override: path: String, tile_w: u32, tile_h: u32. When None, the app default font is used.
  • Behavior — plain data struct of runtime behavioral properties: passable: bool, opaque: bool. Returned by Archetype::behavior(); new properties added here require no match arms elsewhere.
  • Archetype (Copy, PartialEq) — enum of named element types: Empty, Wall, ErrorBlock. Each variant provides behavior(), name() (used in map files), and default_glyph() (used by the editor when stamping a cell). ErrorBlock is a sentinel for unknown archetype names — renders as yellow ? on red.
  • ALL_ARCHETYPES: &[Archetype] — ordered list of valid editor choices (excludes ErrorBlock).
  • 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>. cells is pub(crate). is_passable(x, y) checks objects before the grid cell: an impassable object blocks even over a passable floor tile.
  • Playerx: i32, y: i32
  • ObjectDef — scripted object placed on the board: x, y, glyph: Glyph, passable: bool, opaque: bool, script_name: Option<String>. passable defaults false and opaque defaults true in map files. Its init()/tick(dt) hooks are run by the scripting runtime (see script.rs).
  • PortalDef — parsed from map files, stored on Board; not yet runtime-wired
  • GameState — holds board: Rc<RefCell<Board>>, the message log: Vec<LogLine>, and a ScriptHost. The board sits behind Rc<RefCell<…>> as a sibling of the ScriptHost so 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 through board() -> Ref<Board> / board_mut() -> RefMut<Board> (there is no pub board field). try_move moves the player; run_init() runs object init() hooks once at startup; tick(dt) runs object tick(dt) hooks every frame. After each script batch, apply_commands() drains the script command queue: Log/Errorlog, 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> } and LogLine { spans: Vec<LogSpan> } — a UI-agnostic styled message (colors are core Rgba8, not a front-end type). LogLine::raw(), a chainable push(), and append() build messages; each front-end converts a LogLine to its own styled text at render time.

kiln-core/src/script.rs — Rhai scripting runtime:

  • ScriptHost — owns the Rhai Engine, the compiled scripts referenced by a board's objects (compiled once per name), a per-object persistent Scope, and a shared command queue. Built with ScriptHost::new(&Rc<RefCell<Board>>) (registers the API, compiles scripts, reports compile/unknown-script failures as GameCommand::Error; runs nothing).
  • Lifecycle hooks per object: init() (zero-arg) and tick(dt) (elapsed seconds as f64), both optional — detected via AST::iter_functions() by name and arity. Driven by run_init() / run_tick(dt); runtime errors become Error commands, not fatal.
  • Reads (direct): scripts read through BoardView — a read-only handle (getters only, so read-only by construction) pushed into each scope as board, e.g. board.player_x, board.player_y, board.width, board.height. Each getter briefly borrows the shared Board.
  • Writes (command queue): host fns move(dir), set_tile(n), log(s) push a Command { source, kind: GameCommand } (GameCommand = Move(Direction) / SetTile(u32) / Log(LogLine) / Error(String)) into a shared Rc<RefCell<Vec<Command>>>, drained by GameState::take_commands and applied after the batch. This is how scripts mutate without a &mut GameState borrow.
  • Sender identity: source (which object issued a command) rides the per-call tagrun calls call_fn_with_options(...with_tag(object_index)...) and the host fns read it via NativeCallContext::tag(). Scripts write move(north) without naming themselves; north/south/east/west are Direction constants in scope, and impl From<Direction> for (i32,i32) gives the delta.
  • GameState (hence the Engine/Scope) is single-threaded / not Send; fine for kiln-tui.
  • TODO in-code: Command.source (and ObjectRuntime.object_index) are array indices into Board::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:

  • MapFile and friends — serde Deserialize types for TOML map files
  • TileIndex#[serde(untagged)] enum accepting either Num(u32) or Chr(char); lets map files use tile = " " (char) or tile = 32 (integer) interchangeably
  • FontHeader — deserializes the optional [font] section (path, tile_w, tile_h)
  • impl From<MapFile> for Board — converts a parsed file into a ready-to-use Board; unknown archetype names produce ErrorBlock
  • pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>> — reads and converts a .toml map file
  • pub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>> — serializes Board back 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::load before touching the terminal so load errors print to a normal screen.
  • ratatui::init() → detect TerminalCaps → push Kitty flags when supported → run loop → pop Kitty flags → ratatui::restore().
  • run is a ~30 FPS real-time loop: it event::polls only until the next frame deadline (so input stays responsive) and otherwise wakes to advance the game by the real elapsed dt via GameState::tick. It acts on Press and Repeat key events (so holding a key moves continuously) and ignores Release (which the Kitty protocol also emits, and which would otherwise double-fire each move). Arrow keys move the player via GameState::try_move; l toggles the log panel; q/Esc quit. Mouse capture is enabled so the log panel scrolls (wheel) and resizes (drag the divider). game.run_init() runs object init() hooks after the board is loaded and before the loop.
  • draw wraps the board in a bordered Block titled 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_line converts each LogLine).
  • Ui { log_open, log_height, scroll } — view-only panel state kept in the front-end (not on GameState).

kiln-tui/src/cp437.rs — CP437 → Unicode mapping:

  • CP437: [char; 256] — full code-page-437 table (graphic glyphs for 0x000x1F, box-drawing for 0xB00xDF, etc.).
  • tile_to_char(tile: u32) -> char — indexes the table for tile < 256; falls back to char::from_u32 then 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> — a ratatui::widgets::Widget that 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_enhancement is a real query/response (supports_keyboard_enhancement()); truecolor reads $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; when truecolor, 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. Enabling REPORT_EVENT_TYPES is why the input loop must filter out Release events.

kiln-egui modules (no longer a workspace member)

kiln-egui/src/font.rs — bitmap font loading and UV mapping:

  • BitmapFont — wraps an egui TextureHandle (preprocessed to opaque-white / transparent) plus tile dimensions. The top-left pixel of the source PNG defines the "background" color; those pixels become TRANSPARENT, all others become WHITE. At render time, painter.image(…, tile_uv, fg_color) tints white pixels to fg and transparent pixels reveal the bg fill behind.
  • BitmapFont::load(ctx, path, tile_w, tile_h) — loads from disk
  • BitmapFont::from_bytes(ctx, bytes, tile_w, tile_h, label) — loads from embedded bytes
  • BitmapFont::create_placeholder(ctx) — 16×16 grid of 8×8 procedural tiles (bit-pattern bars), used as fallback when no font file is present
  • BitmapFont::tile_uv(tile) — returns a UV Rect for the given tile index (left-to-right, top-to-bottom). Unit-tested via compute_tile_uv.
  • BitmapFont::cell_size(zoom) — pixel size (Vec2) of one rendered cell at the given integer zoom; centralizes the tile_w * zoom math used across rendering and hit-testing
  • BitmapFont::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 existing FontSpec or defaults (8×16, empty path)
  • show(ctx, open, state, font_spec) — floating dialog with file browser (rfd::FileDialog), tile dimension DragValue controls, scrollable tilesheet preview with grid overlay, and Apply / Use default buttons. Uses a should_close flag to avoid the egui .open() borrow conflict.

kiln-egui/src/main.rs — app entry point and frame loop:

  • AppMode enum (Play | Edit) — gates arrow-key input; toggles the side panel and viewport mode
  • App holds GameState, AppMode, EditorState, default_font: BitmapFont, and board_font: Option<BitmapFont>
  • App::new(board, egui_ctx) — loads assets/vga-font-8x16.png as the default font (falls back to create_placeholder); loads per-board font from board.font if present
  • App::update is a thin sequence of phase methods: handle_input (table-driven arrow keys, Play only) → menu_bar (File Save/Save As via save_to/save_as, Exit, mode toggle) → try_show_script_editor (returns true to take over the frame) → editor side panel + font reload → show_boardhandle_board_clickshow_glyph_pickers → object-glyph writeback
  • App::active_font() — resolves the per-board font or the default; used wherever an exclusive borrow of self is not also needed
  • App::show_board(&self) -> Option<(usize, usize)> — draws the viewport and returns the clicked cell (Edit mode); the click is dispatched to handle_board_click(&mut self, cx, cy) after show_board returns, so the &mut handler never conflicts with the font borrow
  • Font change detection: font_spec_before is snapshotted before show_editor_panel; if changed, apply_font_spec reloads board_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 (not active_font()) so the font borrow stays disjoint from the &mut self.editor/&mut board borrows
  • Play mode: arrow keys move player; render::board_origin centers or player-tracks the viewport
  • Edit mode: ScrollArea::both() wraps the board; click-to-paint stamps (editor.palette.glyph, editor.palette.selected); calls editor::show_editor_panel and glyph_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 fixed CELL_W/H — cell size comes from the active BitmapFont
  • rgba8_to_color32(c) / color32_to_rgba8(c) — inverse bridges between core color::Rgba8 and egui Color32
  • paint_glyph(painter, rect, glyph, font)rect_filled with glyph.bg, then painter.image tinted by glyph.fg
  • glyph_preview_button(ui, glyph, font) -> Response — allocates a one-cell swatch (unzoomed), paints glyph, returns the click response; used by the Palette and Objects tabs
  • draw_glyph(painter, origin, x, y, glyph, font, zoom) — sizes cell via font.cell_size(zoom), delegates to paint_glyph
  • draw_board(painter, origin, board, font, zoom) — draws all cells then player overlay
  • draw_object_overlays(painter, origin, board, font, selected, zoom) — highlights all object cells in the Objects editor tab; the selected object gets a brighter border
  • board_origin(available, board_w, board_h, player, font, zoom) — centers board or clamps to player with no empty space
  • pos_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:

  • EditorTab enum (Palette | Objects | Board | Scripts | World) — which tab is active
  • EditorStatetab: EditorTab plus four concern-grouped sub-structs so each tab takes only the borrow it needs:
    • palette: PaletteStateselected: Archetype, glyph: Glyph, picker_open: bool (selecting a new archetype resets glyph to its default)
    • font_dialog: FontDialogopen: bool, state: FontDialogState
    • objects: ObjectEditselected: Option<usize>, picker_open: bool, editing_glyph: Glyph, placing: bool
    • scripts: ScriptEditediting: 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) — takes open: &mut bool and glyph: &mut Glyph directly; no dependency on EditorState
  • 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 at tile_w × tile_h per 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 = """
############################################################
#                                                          #
############################################################
"""

[[objects]]          # optional
x = 10
y = 5
tile = "#"
fg = "#aa3333"
bg = "#000000"
passable = true
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. 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) (dirnorth/south/east/west), set_tile(n), and log(s).

Key design decisions

  • Behavior and Archetype are separate typesArchetype is the named class of a thing (Wall, Empty, Object); Behavior is its runtime properties (passable, opaque). Adding a new property means adding a field to Behavior, not a match arm at every call site.
  • cells: Vec<(Glyph, Archetype)> — each 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.
  • Archetypes are referenced by name in map files — so ALL_ARCHETYPES can be reordered or extended without breaking saved games.
  • 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.
  • 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.