Files
kiln/CLAUDE.md
T
2026-06-20 18:46:02 -05:00

64 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) — 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 CLAUDE.md to reflect any new modules, types, or behaviors added during the session.
  2. Run the /simplify skill on changed code.
  3. Commit everything with a summary message.

Commands

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).

kiln-core modules

The core types that were formerly monolithic in game.rs are now split into focused modules. game.rs contains only GameState; the data types live in their own files.

kiln-core/src/glyph.rs — per-cell visual:

  • Glyph (Copy, Eq, Hash) — tile: u32 (tilesheet index), fg/bg: Rgba8. Glyph::player() is a const fn (tile 64 @, white on dark blue); all other glyphs come from map files. Hash is hand-implemented via packed u32 representations so it stays in sync with Eq.

kiln-core/src/cp437.rs — tile-index → character mapping (pub):

  • CP437: [char; 256] — full code-page-437 table (graphic glyphs for 0x000x1F, box-drawing for 0xB00xDF, etc.). tile_to_char(tile: u32) -> char indexes it for tile < 256; falls back to char::from_u32 then a blank space. Unit-tested. Lives in core (not a front-end) because it is the meaning of a tile index under the default font; used by kiln-tui's renderer (render.rs) and kiln-ui's glyph picker alike.

kiln-core/src/colors.rs — the named color palette (pub):

  • NAMED_COLORS: [(&str, Rgba8); 16] — the 16 EGA/VGA palette colors as (name, color) pairs in palette order (Black, Blue, …, White). The single source of truth: script::register_global_constants builds the Rhai Black/Blue/… "#RRGGBB" constants from it, and kiln-ui's glyph picker builds its named-swatch strips from it, so the two never drift.

kiln-core/src/archetype.rs — element taxonomy:

  • Archetype (Copy, PartialEq, Hash) — enum of named element types: Empty, Wall, Crate, HCrate, VCrate, Pusher(Direction), ErrorBlock. Each variant provides behavior(), name() (used in map files), and default_glyph(). Crate pushable any direction (CP437 ■, char 254); HCrate/VCrate pushable east/west (↔, char 29) / north/south (↕, char 18) only. Pusher(dir) (pusher_north|south|east|west) is a map-file keyword only: at load it expands into a scripted object (see [builtin_scripts]), never a terrain cell. ErrorBlock is a sentinel for unknown archetype names (yellow ? on red). TryFrom<&str> parses by name; unrecognized names return Err (the map loader substitutes ErrorBlock).

kiln-core/src/builtin_scripts.rs — archetypes that are really scripted objects (pub(crate)):

  • archetype_script(arch) -> Option<&'static str> — the dispatch (one arm per script-backed archetype) returning the embedded Rhai source (include_str!); currently Pusher(_) -> scripts/pusher.rhai. When resolve_entry (in layer.rs) hits such an archetype it emits an ObjectDef carrying that source in builtin_script plus a BUILTIN_<archetype> tag instead of a terrain cell.
  • BUILTIN_TAG_PREFIX ("BUILTIN_"), builtin_tag(arch) -> String, archetype_from_builtin_tag(tag) -> Option<Archetype> — the tag convention. The script reads its tag for per-instance params (the pusher reads it for its direction, since Me isn't visible inside Rhai helper functions); map_file::save uses it to collapse the object back into its archetype keyword so maps round-trip. scripts/pusher.rhai self-propels via move(dir) (which already shoves chains via step_object/push), pacing itself with delay to the old ~0.5 s cadence.

kiln-core/src/utils.rs — shared primitive types (pub(crate)):

  • Pushable (No/Any/Horizontal/Vertical) — which directions a solid may be pushed. allows(dir) -> bool.
  • Behavior — plain data struct from Archetype::behavior(): solid: bool, opaque: bool, pushable: Pushable.
  • ObjectId = u32 — stable identifier for board objects.
  • Solid — the single solid occupant of a cell, returned by Board::solid_at: Player, Cell(Archetype), or Object(ObjectId).
  • Player { x: i32, y: i32 } — current player position.
  • PortalDef { x, y, target_map, target_entry } — parsed from map files, not yet runtime-wired.

kiln-core/src/object_def.rs — scripted objects (pub(crate)):

  • ObjectDef — a scripted tile: x, y, z: usize (layer index, drives draw order), glyph: Glyph, solid: bool (default true), opaque: bool (default true), pushable: bool (default false), script_name: Option<String>, builtin_script: Option<&'static str> (embedded source set when a script-backed archetype like a pusher is expanded at load; takes precedence over script_name; not serialized — see [builtin_scripts]), tags: HashSet<String>, name: Option<String>. The optional name is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to None (nonfatal). ObjectDef::new(x, y) constructs with defaults (z = 0). ObjectDef::default_glyph() returns tile 63 (?) yellow on black.

kiln-core/src/layer.rs — palette layers (the map-file/draw-stack unit):

  • Layer { cells: Vec<(Glyph, Archetype)> } (pub(crate)) — one row-major draw layer. A cell whose glyph.tile == 0 (Glyph::transparent()) draws nothing, so the layer beneath shows through; solidity comes from the archetype, independent of transparency.
  • LayerData { content, fill, sparse, palette: HashMap<String, PaletteEntry> } — serde for one [[layers]] entry. The grid comes from exactly one of three optional fields (precedence contentfillsparse; none ⇒ all spaces): content (a multi-line grid string), fill (one char filling the whole grid — handy for a uniform floor layer), or sparse (a Vec<SparseCell> of { x, y, ch } over an otherwise all-spaces grid — handy for a layer of just a few objects). Only content can mismatch the board dims (hard error); fill/sparse are always exactly sized (a bad fill/ch or out-of-bounds sparse cell is a nonfatal error). PaletteEntry is a single flat struct with a kind: String discriminator plus all-optional fields (tile, fg, bg, generator, solid, opaque, pushable, script_name, tags, name, target_map, target_entry); only the fields relevant to the kind are read. (One flat struct, not an enum, because kind is open-ended — any archetype name or a meta-kind.)
  • build_layer(data, w, h, &mut StdRand, &mut Vec<LogLine>) -> Result<(Layer, Vec<Placement>), String> — resolves the palette (via resolve_entry), validates the grid dims (the only hard Err), and walks the grid building the Layer plus a Vec<Placement> (Object(ObjectTemplate, x, y) / Portal(PortalTemplate, x, y) / Player(x, y)) for the map loader to resolve across layers. Procedural floors roll a fresh glyph per cell from the shared seeded StdRand.
  • resolve_entry maps kindResolved: empty → transparent cell; floor → a generator (per-cell roll) or a fixed visual-only Empty glyph; object/portal/player → a placement; any other string → Archetype::try_from (Ok → terrain cell; Err → visible ErrorBlock + logged error). A portal missing name/target_map/target_entry is dropped to a transparent cell with an error.

kiln-core/src/board.rs — the board data type:

  • Board — the complete game unit (ZZT-style "board"): width, height, layers: Vec<Layer> (bottom→top draw stack; pub(crate)), player: Player, objects: BTreeMap<ObjectId, ObjectDef>, next_object_id: ObjectId, portals: Vec<PortalDef>, board_script_name: Option<String> (name of a board-level script in the world script pool, if any), load_errors: Vec<LogLine> (pub(crate)), registry: HashMap<String, RegistryValue>. Scripts are not stored on Board — they live in [World::scripts]. The visual floor is no longer a separate field: it is just Empty cells with a visible glyph on a lower layer.
  • layer_count() -> usize; get(z, x, y) -> &(Glyph, Archetype) / get_mut(z, x, y) — per-layer cell access (panics OOB).
  • glyph_at(x, y) -> Glyph — the glyph a renderer should display at (x, y). The player draws on top (returns Glyph::player() at its cell); otherwise layers are walked top-down and the first thing that draws wins: a solid object on that layer (always) or a visible non-solid object, else a portal on that layer, else the layer's terrain cell (a solid always draws; a non-solid only if tile != 0). Nothing anywhere → the canonical black Empty glyph. Front-ends call this per-cell instead of managing a separate player overlay.
  • solid_at(x, y) -> Option<Solid> — the cell's single solid occupant (player checked first, then objects, then a solid terrain archetype on any layer via solid_cell_layer).
  • is_passable(x, y) — convenience inverse of solid_at.
  • can_push(x, y, dir) -> bool — read-only: does the chain of pushable solids end in open space?
  • push(x, y, dir) — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). A pushed solid moves within its own layer (found via solid_cell_layer).
  • add_object(obj) -> ObjectId, object_ids_at(x, y), solid_object_id_at(x, y) — object queries.
  • place_archetype(x, y, arch, glyph) — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving any floor untouched: a non-Empty (solid) arch removes a solid object in the cell then writes (glyph, arch) into the existing terrain layer (terrain_layer_at, the single non-Empty cell) or else the top layer; Empty erases — removes every object plus the terrain cell (→ transparent Empty).
  • in_bounds((i32, i32)) — bounds-checks a possibly-negative coord.
  • is_valid() / load_errors() / report_error(msg) — nonfatal load-error surface.

kiln-core/src/game.rs — game-loop logic only:

  • SpeechBubble { object_id, text, remaining } — an active speech bubble created by a script's say(s) call. remaining counts down in GameState::tick; when it reaches zero the bubble is removed. At most one bubble per object (a new say() replaces the old one).
  • SAY_DURATION: f64 — how long a bubble lives (3.0 seconds).
  • Scroll { source: ObjectId, lines: Vec<ScrollLine> } — an active scroll overlay opened by a script's scroll(lines) call. Lives on GameState::active_scroll: Option<Scroll>; front-ends pause ticks while it's Some and close it via close_scroll(choice). ScrollLine is re-exported from action.rs through game.rs so front-ends import both from kiln_core::game.
  • GameState — owns world: World (all boards as Rc<RefCell<Board>> + world scripts) and current_board_name: String (key of the active board). pub log: Vec<LogLine>, scripts: ScriptHost, pub speech_bubbles: Vec<SpeechBubble>, pub active_scroll: Option<Scroll>. Front-ends reach the active board through board() -> Ref<Board> / board_mut() -> RefMut<Board> (both look up world.boards[current_board_name]). current_board_name() -> &str returns the active key. from_world(world: World) -> Self is the primary constructor: clones the start board's Rc<RefCell<Board>> for the ScriptHost, compiles scripts from world.scripts. new(board) and with_scripts(board, scripts) are #[cfg(test)]-only sugar that build a minimal single-board World. try_move(dir) moves the player (pushing if possible) and fires bump(-1) on any solid object walked into. run_init() runs object init() hooks once at startup. tick(dt) advances cooldowns, expires speech bubbles, and runs tick(dt) hooks every frame. Both end by calling resolve(), which drains the board queue and applies each Action (Loglog, SetTile → source glyph, Move(dir)step_object, Sayspeech_bubbles, Scrollactive_scroll). Resolution is two-phase: mutate the board collecting (bumped, bumper) pairs, drop the borrow, then fire run_bump for each pair. drain_errors() moves script errors into log. close_scroll(choice) clears active_scroll and optionally fires run_send(source, choice, None) to dispatch the player's selection back to the object.

kiln-core/src/action.rs — the Action enum and its Rhai-facing conversion:

  • MOVE_COST: f64 — how long a move occupies an object before it can act again (0.25 s).
  • ScrollLine (pub) — one line of content in a Scroll action: Text(String) (plain, word-wrapped) or Choice { choice, display } (selectable; choice is sent back to the source object when the player picks it).
  • Action (pub(crate)) — deferred mutation emitted by a script and applied by GameState after promotion onto the board queue: Move(Direction), SetTile(u32), Log(LogLine), SetTag { target, tag, present }, Say(String), Delay(f64) (never reaches the board queue — consumed by ScriptHost::drain to pace the object), SetColor { fg, bg }, Send { target, fn_name, arg }, Scroll(Vec<ScrollLine>).
  • action_to_map(action) -> rhai::Map — converts an Action to a Rhai map for Queue.peek() / Queue.pop(). The map always has a "type" key; other keys carry the payload. Scroll emits type only (lines are not inspectable via the map API). Log is flattened to its first span's text.

kiln-core/src/floor.rs — procedural floor generators:

  • A "floor" is just a non-solid, visible Empty cell on a layer (a palette entry with kind = "floor"). This module only owns the generators; the actual per-cell placement happens in layer.rs during load.
  • FloorGenerator (Grass/Dirt/Stone) — procedural textures differing only in color scheme and texture-char probability. from_name(&str) parses the map-file name; generate(&mut StdRand) picks a dark, low-saturation ground color (green/brown/gray) and, with the generator's probability, scatters a lighter texture char (grass , . \ '; dirt . : , ;; stone . ,). Uses tinyrand(already a workspace dep, WASM-safe).FLOOR_SEED (pub(crate)) seeds one StdRandper board build (inmap_file`), threaded through every generator call, so floors are deterministic/testable and depend only on map content.

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(), append(), and LogLine::error() (a red-on-black single-span constructor used for nonfatal load errors) 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, a per-object persistent Scope + output queue + ready timer, and the shared board queue. Built with ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>): the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool. Each object resolves to a (key, source) compiled once per key: a named script keys by its pool name; an object's embedded builtin_script keys by its source text (so identical built-ins, e.g. all pushers, share one AST). Reports compile/unknown-script failures onto the error sink; runs nothing.
  • Lifecycle hooks per object: init() (zero-arg), tick(dt) (elapsed seconds as f64), and bump(id) (the bumper's ObjectId, or -1 for the player), all optional — detected via AST::iter_functions() by name and arity. Driven by run_init() / run_tick(dt) / run_bump(object_id, bumper); runtime errors go to the error sink (drained to the log), not fatal.
  • Reads (direct): read getters (player_x, player_y, width, height) are registered on Rc<RefCell<Board>> (the BoardRef alias, exposed to Rhai under the type name Board) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant Board. blocked(dir) -> bool is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its own move, which is pumped only after its script returns). has_tag(s) -> bool and get_tags() -> Array read the calling object's tag set; objects_with_tag(s) -> Array returns all object ids carrying that tag. my_name() -> String returns the calling object's name (or "" if unnamed); object_id_for_name(s) -> i64 looks up an object by name and returns its id, or 0 if not found. Each getter briefly borrows the shared Board.
  • Actions & rate limiting (two-tier queues): host fns move(dir), set_tile(n), log(s), say(s), scroll(lines) append an Action (Move/SetTile/Log/Say/Scroll; MOVE_COST = 0.25 s for Move, else 0) to the issuing object's output queue (routed by the per-call tag via a HashMap<usize, ObjQueue>). scroll(lines) takes a Rhai array where each element is a string (text line) or a two-element array [choice_key, display_text] (selectable choice). ScriptHost::pump(i) promotes ready actions onto the shared board queue (Vec<BoardAction { source, action }>): the leading run of zero-cost actions plus at most one timed action, which arms that object's ready timer and ends the pump. While a ready timer is > 0 nothing is pulled (this caps object speed); advance_timers(dt) counts them down each frame. GameState drains the board queue with take_board_queue() and applies it after the batch — so scripts mutate without a &mut GameState borrow. Errors (compile/runtime) bypass the queues via the error sink (take_errors()).
  • The Queue object (a handle to the calling object's output queue) is pushed into each scope; scripts call Queue.length(), Queue.clear(), Queue.peek() (front action as a Rhai map, or () if empty), and Queue.pop() (same, removes the front). Safe to mutate from script because the host only touches output queues between calls (during pump).
  • Sender identity: source (which object issued an action) rides the per-call tagrun calls call_fn_with_options(...with_tag(object_id)...) and the host fns read it via NativeCallContext::tag() (decoded back to an ObjectId by source_of). 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.
  • Sender identity uses stable ids: BoardAction.source, ObjectRuntime.object_id, and the QueueMap keys are all the object's ObjectId (matching Board::objects' BTreeMap keys), so they survive object spawn/destroy/reorder. The per-call tag carries the id as an i64 (id 0 — never valid — is the no-source fallback). ScriptHost::objects is still a Vec<ObjectRuntime> built by iterating the board map, so it stays in ascending-id order.

kiln-core/src/map_file.rs — per-board serde shell + load/save orchestration (the bulk of the per-layer work lives in layer.rs):

  • MapFile { map: MapHeader, layers: Vec<LayerData> } — serde for one board: a [map] header (name, width, height, optional board_script_name; no player_start — the player is a kind = "player" palette char) plus the [[layers]] stack. Does not include scripts (those live in World::scripts).
  • TileIndex (pub) — #[serde(untagged)] enum accepting either Num(u32) or Chr(char); lets map files use tile = " " (char) or tile = 32 (integer) interchangeably. parse_color/color_to_hex (pub(crate)) are shared with layer.rs.
  • impl TryFrom<MapFile> for Board — the load conversion: builds each layer via layer::build_layer (collecting object/portal/player placements with their layer z), then runs the cross-layer validations. Best-effort/nonfatal: only a layer grid-dimension mismatch returns Err; everything else is recorded on Board::load_errors (see Board::is_valid) — the player must appear exactly once (missing → (0,0); multiple → first) and wins its cell; one solid per cell across all layers (a stacked solid is dropped); object names board-unique (a dup is cleared), portal names unique (a dup is dropped). Object ids are assigned in layer-then-reading order.
  • impl From<&Board> for MapFile — save: emits one [[layers]] per board layer (deduping each unique (Glyph, Archetype) to a palette char), with objects/portals grouped by z and the player written onto the top layer. Generators baked to literal glyphs at load are saved as fixed floor glyphs (the generator name is not recovered).
  • pub fn load(path: &str) -> Result<Board, …> — reads a single-board .toml file. Production code uses world::load instead.
  • pub fn save(board: &Board, path: &Path) -> Result<(), …> — serializes Board back to a single-board TOML file.

kiln-core/src/world.rs — world type and world-file loading:

  • World — the runtime representation of a .toml world file: name: String, start: String (key of the starting board), scripts: HashMap<String, String> (named Rhai source, shared across all boards), boards: HashMap<String, Rc<RefCell<Board>>> (all boards, always present). Every board is wrapped in Rc<RefCell<Board>> so multiple shared refs can coexist — GameState clones the active board's Rc for its ScriptHost, and all boards remain in world.boards at all times (no board is ever "removed" when active).
  • pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>> — reads a world .toml file, converts each [boards.NAME.*] subtable via TryFrom<MapFile> for Board, wraps each in Rc::new(RefCell::new(...)), and validates that world.start matches a board key.

kiln-ui modules

Reusable terminal UI widgets on ratatui, mostly game-agnostic. A widget owns its presentation + input handling and is generic over a host context Ctx it mutates only through callbacks, so a front-end decides what a choice does without the widget knowing about game/world types. The front-end owns the event loop and routes input to the widget while it is focused. The lone exception is glyph_dialog, which depends on kiln-core for Glyph/Rgba8 and cp437::tile_to_char (the accepted, documented first kiln-core dependency — it would matter only if kiln-ui were ever split into a standalone crate).

kiln-ui/src/lib.rs — crate root:

  • dim_area(area, buf) (pub) — dims every cell in area (background tint for a modal overlay). Shared scaffolding: used by [dialog] here and re-borrowed by kiln-tui's overlay::render_overlay_frame so both modal styles dim identically.
  • CursorOverlay (pub trait) + render_overlay(frame, &impl CursorOverlay) (pub) — a modal overlay paints into a &mut Buffer and returns Option<Position> (where the terminal caret goes) from render(area, buf), instead of placing the cursor itself. render_overlay is the one place that needs a Frame: it calls render over frame.area() then applies the returned position via set_cursor_position. The split exists because a ratatui Widget only sees a buffer (can't move the hardware cursor), and it makes render unit-testable headless. Implemented by Dialog and GlyphDialog, which also impl Widget for &Dialog/&GlyphDialog (forwarding to CursorOverlay::render and discarding the caret) so they can be drawn with frame.render_widget where the cursor isn't needed.
  • Modules: pub mod dialog; pub mod code_editor; pub mod text_field; pub mod glyph_dialog; (widgets) plus mod clipboard; mod text; (pub(crate) shared internals — see below).

kiln-ui/src/clipboard.rs / text.rspub(crate) shared internals for the text widgets:

  • clipboard::Clipboard — system clipboard via arboard when available, always backed by an internal buffer (so copy/paste work headless / on the web); set/get write-through and prefer-system-on-read. Used by both code_editor and text_field.
  • text::{byte_of, char_cells, display_width, next_word, prev_word, super_to_ctrl, TAB_WIDTH} — char↔byte index, display-width (tab stops + unicode-width wide chars), within-line word boundaries, and the ctrl key rewrite, shared by both editors.

kiln-ui/src/dialog.rs — modal dialog widgets:

  • Dialog<Ctx> — an open dialog generic over the host context its callback mutates. Built via Dialog::text(title, initial, on_done) (a single free-text field; on_done: FnOnce(Option<String>, &mut Ctx), None on cancel) or Dialog::list(title, items, allow_create, on_done) (a filter field over a selectable list; on_done: FnOnce(ListDialogResponse, &mut Ctx)). Holds a DialogBody (Text/List) plus the boxed callback. Each field is a [TextField] (the in-house single-line editor).
  • ListDialogResponse { Select(String), Create(String), Cancel } — list outcome: an existing entry, a newly-typed value (only when allow_create), or cancel. DialogResult { Continue, Submit, Cancel } — what a key event did.
  • handle_event(&Event) -> DialogResultEscCancel; EnterSubmit only when OK is enabled (always for text; for a list, a valid entry is selected or allow_create + non-empty); Up/Down move a list's selection; other keys edit the field (and re-clamp the list selection to the new prefix filter). finish(result, &mut Ctx) consumes the dialog and fires its callback. The host pattern: take() the dialog out of its slot on Submit/Cancel, then call finish so the callback gets an un-borrowed &mut Ctx.
  • Dialog draws via its CursorOverlay impl (render(area, buf) -> Option<Position>; host renders it through render_overlay) — dims the screen, draws a centered bordered box. A text dialog centers its field box (h+v) with a distinct FIELD_BG background so it reads as an input box; a list dialog puts the filter field atop the scrolling, selection-highlighted list. Both show an [enter] OK [esc] Cancel footer (OK dimmed when disabled). render returns the active field's caret position (the host places the real terminal cursor). Unit-tested (filtering/clamping, OK-enabled rule, Select/Create/Cancel/text outcomes, headless render) with a dummy Ctx.

kiln-ui/src/glyph_dialog.rs — the glyph picker (the one kiln-core-dependent widget):

  • GlyphDialog<Ctx> — a modal picker for a [Glyph] (CP437 tile index + fg/bg colors), same API shape as Dialog. Built via GlyphDialog::new(title, initial: Glyph, on_done); on_done: FnOnce(Option<Glyph>, &mut Ctx) fires Some(glyph) on OK, None on cancel. Holds the selected tile: u32, two ColorPickers (fg/bg), and a Focus (Grid/FgStrip/FgField/BgStrip/BgField).
  • ColorPicker (private) — one color selector: a selected slot index over a strip of the 16 kiln_core::colors::NAMED_COLORS swatches plus a final custom slot (CUSTOM == NAMED_COLORS.len()), and a 6-hex TextField that is the source of truth (color() parses it). Picking a named swatch fills the field via TextField::set; the custom slot leaves it user-editable. Seeds from an Rgba8 by matching a named color (else the custom slot).
  • handle_event(&Event) -> DialogResult (reuses dialog::DialogResult) — EscCancel; EnterSubmit only when both colors resolve; Tab/Shift-Tab/BackTab cycle focus (skipping a color's *Field stop unless it's on the custom slot). On the grid, arrows move the 32×8 selection (clamped); on a strip, Left/Right pick a swatch and Down enters the custom field; in a custom field, keys edit it and Up returns to the strip. finish(result, &mut Ctx) fires the callback (a Submit with both colors valid yields Some(Glyph{..})).
  • Drawing is its CursorOverlay impl (render(area, buf) -> Option<Position>; host renders it through render_overlay) — dims + centered bordered box; renders the 256-char grid via kiln_core::cp437::tile_to_char (chosen fg/bg when both colors resolve, else gray-on-black; selected cell is a bright cursor when the grid is focused, else reversed), then for each color a swatch strip (one solid cell per named color + the custom slot; selected slot marked , custom slot otherwise +) above a hex row (an editable #RRGGBB box, red when invalid, when on the custom slot; else the dimmed name + hex of the selected named color), and the footer (which previews the chosen glyph). Returns the focused custom field's caret position (or None). Unit-tested (named/custom seeding, strip selection fills hex, focus cycling skips unreachable fields, Down/Up enters/leaves field, grid clamp, invalid-hex disables OK, Submit/Cancel, headless render) with a dummy Ctx.

kiln-ui/src/text_field.rs — a single-line editable text field (a one-line sibling of code_editor, used by dialog's text/filter inputs; replaced tui-input):

  • TextFieldtext: String + a char-indexed cursor + a Clipboard + an optional max_length. new(initial) (unbounded) or sized(initial, max_length) (truncates the seed and caps typed input); value(), display_cursor() (caret's display column), set(text) (replace contents, truncated to max_length), and handle_key(KeyEvent) -> bool (true if consumed). Handles printable insert, Backspace/Delete, Left/Right (+ ctrl word-moves), Home/End, and ctrl/+v paste (newlines stripped); leaves Esc/Enter/Up/Down to the caller. Presentation is the caller's job (it reads value()/display_cursor()). No selection yet. Unit-tested (typing, delete, movement, paste, wide-char cursor, max-length cap, set).

kiln-ui/src/code_editor.rs — a hand-written modeless, keyboard-only code editor (no editor crate; edtui was tried and dropped — it panicked on bare Shift via an unimplemented!() keycode arm and lacked basics like line-wrap):

  • CodeEditor — edits one script's text: lines: Vec<String>, a char-indexed cursor/anchor (selection), desired_col (vertical-move column), scroll (top row + left display-col, cursor-following), undo/redo Snapshot stacks, a cached HighlightParts, and a Clipboard (system via arboard when available, always backed by an internal buffer so copy/paste work headless / on the web). Public API (unchanged across the edtui→custom swap, so kiln-tui needed no edits): new(name, text), name(), text() (lines joined with \n), handle_event(&Event) -> CodeEditorOutcome { Continue, Exit }, draw(&mut self, frame, area).
  • Input (handle_event): is mapped to ctrl up front; then printable keys insert, Enter/Backspace/Delete edit (Backspace/Delete at a line edge join lines, Left/Right wrap across line ends), arrows move (shift extends the selection), ctrl+Left/Right jump by word (text::{prev,next}_word, wrapping at line ends) and ctrl+Up/Down jump by paragraph (to the nearest blank/whitespace-only line, via is_blank_line), Home/End/PageUp/PageDown, ctrl+c/x/v (system clipboard), ctrl+a (select all), ctrl+z/y (undo/redo), EscExit. Bracketed Event::Paste inserts text. Unknown keycodes are ignored (the whole point — no panic on Shift/F-keys). Undo coalesces a run of typing (or deletes) into one step; movement/structural edits break the run.
  • Rendering (draw): a titled border, a line-number gutter, and per-line styled text. One width model (char_cells: tabs → next 4-stop, wide chars via unicode-width) drives the cursor X, selection background, and horizontal scroll together. Highlighting: build_highlight_parts() parses the embedded resources/rhai.sublime-syntax into a one-syntax SyntaxSet + base16-ocean.dark theme (cached); draw runs syntect::easy::HighlightLines from line 0 to the viewport bottom each frame (small scripts), mapping syntect colors to Color::Rgb. Long lines clip with the cursor-following horizontal scroll (no soft-wrap). Mouse is out of scope for now.
  • Unit-tested (pure logic, no TTY): syntax parses, text round-trips, typing inserts, Left/Right line-wrap, Backspace line-join, shift-select delete, bracketed-paste-over-selection, undo/redo + coalescing, ⌘→ctrl, Esc exits.

kiln-tui modules

The terminal player + world editor, depending on both kiln-core and kiln-ui. 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. The app is one of two top-level [Mode]s — Play (live, ticking game) or Edit (a freshly reloaded, non-ticking world) — and the active mode plus a Ui value are threaded through the loop.

Drawing rule: prefer ratatui primitives. Before writing manual cell-by-cell buffer code, check whether a Block, Paragraph, Layout, Line::centered(), or other ratatui widget/method achieves the same result. If a small design change (e.g. fixed vs. content-driven sizing) would unlock a ratatui-native approach, raise it as a question rather than writing manual code. The documented exceptions are: the background dimming pass (kiln_ui::dim_area, used by render_overlay_frame and the dialog widgets — no ratatui primitive for "tint the whole buffer"); the tail characters in SpeechBubblesWidget (speech.rs): the tail-join on the bottom border and the column drawn below the box down to the source object; and the blinking editor cursor + LFSR wipe blend.

kiln-tui/src/main.rs — entry point, real-time loop, and frame dispatch:

  • Parses a single positional arg (the world file path); prints usage and exits non-zero if missing. Loads the world via kiln_core::world::load before touching the terminal so load errors print to a normal screen, then GameState::from_world(world), wrapped in Mode::Play. The world path is kept on Ui::world_path so the editor / play-reload can reload it.
  • ratatui::init()EnableMouseCapture → detect TerminalCaps → push Kitty flags when supported → run loop → pop Kitty flags → DisableMouseCaptureratatui::restore(). game.run_init() runs object init() hooks before the loop.
  • run is a ~30 FPS real-time loop: event::polls only until the next frame deadline (so input stays responsive) and otherwise wakes to advance by the real elapsed dt via Ui::tick(dt, mode). Acts on Press/Repeat and ignores Release (the Kitty protocol emits these; acting on them double-fires moves). Input is routed by current_input_mode: Menuhandle_menu_input; otherwise (unless Ignore) it matches on ModePlayhandle_board_input/handle_scroll_input, Edithandle_editor_input/handle_dialog_input. After ticking it calls apply_pending_mode and exits once should_quit is set and no close animation is running.
  • apply_pending_mode(mode, ui) — applies a PendingMode a menu action requested: reloads the world from world_path and swaps *mode to Mode::Edit(EditorState::new(world)) (enter editor) or a fresh Mode::Play (play world, re-running init()); a reload failure is logged to the play game and leaves the mode unchanged.
  • draw(frame, mode, ui) — renders the active mode (draw_play or editor::draw_editor), then the overlay layer in precedence animation > menu > (Play: scroll / Edit: dialog): an Overlay-layer animation via AnimWidget, else MenuWidget, else the play scroll overlay (ScrollOverlayWidget) or — via kiln_ui::render_overlay — the editor's glyph picker (when open) or dialog (both CursorOverlays). draw_play wraps the board in a bordered Block (board-name title; latest log previewed in the bottom border when the panel is closed). draw_board_area initializes a pending portal transition (now that the inner area is known) and renders either the board-layer wipe animation or the live board + speech bubbles.

kiln-tui/src/mode.rs — top-level application mode:

  • Mode { Play(GameState), Edit(EditorState) } — exactly one is live at a time (play and edit are mutually exclusive: entering the editor drops the running game; returning to play rebuilds it fresh from the world file). PendingMode { EnterEditor, PlayWorld } — a deferred Play↔Edit switch requested by a menu closure (which only has &mut Ui) and applied by the run loop, mirroring the should_quit pattern.

kiln-tui/src/editor.rs — world editor mode:

  • EditorState — a non-ticking editing session over a freshly reloaded World: world, board_name (board being edited), menu: Vec<MenuLevel> (sidebar menu stack, for breadcrumb + escape-to-parent), dialog: Option<Dialog<EditorState>>, code_editor: Option<CodeEditor> (the open script editor; replaces the board view), glyph_dialog: Option<GlyphDialog<EditorState>> (the open glyph picker overlay), a blinking cursor, the drawing tool (current_archetype + current_glyph — the thing stamped on space, defaulting to a wall — and draw_mode, the toggle that re-stamps on every cursor move), sidebar_width (mouse-resizable), blink state, last_board_area (written at draw, read for right-click hit-testing), and editor-side log.
  • MenuLevel (Main/World/Floor/Terrain/Machines/Pushers) — a sidebar menu level; title() (breadcrumb segment) and entries() -> Vec<MenuEntry> (the level's lines). A MenuEntry is a key-activated Item ([k] Label + a boxed FnOnce(&mut EditorState) action), a CurrentValue (a boxed Fn(&EditorState) -> String shown indented beneath an item, e.g. the world name), or a Blank/Separator spacer. An item's action opens a dialog (World/scripts/boards), pushes a child level (MainWorld/Floor/Terrain/Machines; MachinesPushers), or sets the drawing tool (Terrain/Pushersset_current(arch)). The menu is a static letter-keyed stack — picking from a list is always a dialog, so arrow keys never conflict with the board cursor.
  • Methods: breadcrumb(), current_menu(), menu_escape(ui) (pop a level, or at the root open the overlay editor_menu_items()), menu_key(c) (dispatch a letter → the matched entries() item's action via MenuLevel::perform_key), and the open_{name,entry,scripts,boards}_dialog builders (name → text dialog writing world.name; entry → list dialog writing world.start; boards → select-only; scripts → opens the script in code_editor). open_script_editor(name) builds a CodeEditor from world.scripts[name]; close_script_editor() saves its text back into world.scripts (in-memory, like the other dialogs).
  • Drawing: set_current(arch) sets current_archetype and resets current_glyph to its default; open_glyph_picker() seeds a GlyphDialog from current_glyph and writes the chosen glyph back (bound to g); place_current() stamps the current archetype+glyph at the cursor via Board::place_archetype; toggle_draw_mode() flips draw_mode. place_current is bound to space and toggle_draw_mode to tab in handle_editor_input; move_cursor/set_cursor_at_screen also call place_current when draw_mode is on and the cursor enters a new cell. The sidebar's bottom drawing-controls footer (draw_footer_lines) shows the archetype name, [g] Glyph + a live colored preview, [spc] Place, and [tab] Draw mode (on/off).
  • editor_menu_items() — the overlay editor menu ([p] play, [q] quit, [esc] resume), opened at the root of the menu tree. handle_dialog_input(event, ed) — forwards to the open dialog and, on Submit/Cancel, takes it out and calls finish(.., ed). handle_glyph_dialog_input(event, ed) — the same pattern for the open glyph_dialog. handle_code_editor_input(event, ed) — forwards to the open code editor and, on Exit (Esc), calls close_script_editor(). draw_editor(frame, ed, ui) / editor_layout(..) — the code editor (when open) else the board (with blinking cursor + coord readout) in the left area, a resizable right-hand sidebar (breadcrumb title + the menu choices, each with its optional current-value line), and the optional full-width log panel.

kiln-tui/src/ui.rs — front-end presentation state (not on GameState):

  • Ui { log: LogState, overlay: ScrollOverlayState, pending_transition, active_animation: Option<Box<dyn Animation>>, menu: Option<MenuState>, should_quit, world_path, pending_mode }. While active_animation is Some all input is suppressed. tick(dt, mode) advances the active animation (and, on completion, clears scroll/menu state when returning to Board), then ticks the active mode — the game only in Play + Board input mode (so an open scroll/menu pauses it), the editor's cursor blink in Edit; a scroll that appears from a tick starts an open animation.
  • open_menu(items) / begin_close_menu() — start the menu open/close animations (snapshotting MenuState). begin_close(choice, game) — set the player's scroll choice and start the scroll close animation. start_transition(old, new, area) — build the board-wipe once the inner area is known. scroll_lines(delta) / scroll_menu(delta) — clamped overlay/menu scrolling.

kiln-tui/src/input.rs — input mode + dispatch:

  • InputMode { Board, Scroll, Menu, Editor, Dialog, CodeEditor, GlyphDialog, Ignore, Frozen }. current_input_mode(mode, ui) — derives the mode: an active animation declares its own (input_mode_during); a pending transition → Ignore; an open ui.menuMenu; else from Mode (EditGlyphDialog when the glyph picker is open, else Dialog when a dialog is open, else CodeEditor when a script editor is open, else Editor; PlayScroll when a scroll is active else Board).
  • handle_board_input (arrows move the player via try_move_with_transition, l toggles log, Esc opens the pause_menu_items() overlay, PageUp/Down + mouse scroll/resize the log), handle_editor_input (arrows move the cursor, l toggles log, g opens the glyph picker, Escmenu_escape, other letters → menu_key, right-click moves the cursor, drag resizes the sidebar), handle_scroll_input (scroll nav + choice letters via resolve_choice), handle_menu_input (clone the matched item's action Rc to drop the ui.menu borrow, then call it). try_move_with_transition captures both board Rcs into ui.pending_transition when a move crosses a portal.

kiln-tui/src/animation.rs — the animation abstraction:

  • Animation trait — update(dt) -> bool (done?), draw(area, buf), layer(), input_mode_during() (default Ignore), input_mode_after(). AnimationLayer { Board, Overlay } (board-area replacement vs. full-screen overlay). AnimWidget<'a>(&'a dyn Animation) — adapts an animation to a ratatui Widget for frame.render_widget.

kiln-tui/src/transition.rs — board-change wipe:

  • TransitionAnimation — pre-renders the old and new boards into off-screen Buffers, then reveals cells in an LFSR-driven pseudo-random order over TRANSITION_DURATION (0.25 s), blending old/new per cell in draw. A Board-layer animation that ends in InputMode::Board. lfsr_step/lfsr_sequence build the maximal-period permutation (16-bit Galois LFSR, poly 0xB400).

kiln-tui/src/overlay.rs — shared overlay window frame:

  • OverlayStyle { bg, border_fg, width_div, height_fraction } and render_overlay_frame(style, progress, area, buf) -> Option<(content_rect, scrollbar_rect)> — dims the background (via kiln_ui::dim_area), sizes/centers an animated-height window (kept even), draws the bordered block, and returns the content + scrollbar rects. Shared by menu.rs and scroll_overlay.rs. (The dialog widgets in kiln-ui draw their own smaller box and only reuse dim_area.)

kiln-tui/src/menu.rs — in-game menu overlay:

  • MenuItem { key: MenuKey, label, action: Rc<dyn Fn(&mut Ui)> } (ActionRc) and MenuKey { Char(char), Esc } — menu actions only touch Ui (signal close/quit/pending_mode), so the same shape works for play and editor menus. MenuState { items, offset, max_scroll }, MenuWidget (fully-open StatefulWidget), and MenuOpenAnimation/MenuCloseAnimation (share the Animation pipeline + render_overlay_frame, blue style). render_menu_at(..) draws one [key] label line per item. pause_menu_items() — the play-mode pause menu ([e] edit, [q] quit, [esc] close).

kiln-tui/src/scroll_overlay.rs — scroll overlay widget:

  • ScrollOverlayState { offset, max_scroll } — input/output state threaded through render. ScrollOverlayWidget<'a>StatefulWidget for the fully-open overlay (½ width × ¾ height, amber style); ScrollOpenAnimation/ScrollCloseAnimation implement Animation for the open/close transitions. render_scroll_at(..) wraps text, centers whitespace-leading lines, prefixes choices with [a]/[b], and draws a scrollbar when content overflows.

kiln-tui/src/render.rs — board → ratatui buffer:

  • BoardWidget<'a> — a ratatui::widgets::Widget that draws the board. Per axis, axis(board_len, view, player) -> (offset, pad, count) (pub): 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 calls Board::glyph_at, which already folds in the player glyph at the player's position — no separate player-overlay pass needed.
  • board_to_screen(area, board, bx, by) -> Option<(u16, u16)> and its inverse screen_to_board(area, board, sx, sy) -> Option<(usize, usize)> (pub) — convert between board cells and terminal coordinates using the same axis math, returning None off-board. Used by the editor's blinking cursor + right-click hit-testing (and mirrored by speech::board_screen_pos).

kiln-tui/src/speech.rs — speech bubble widget:

  • SpeechBubblesWidget<'a> — a ratatui::widgets::Widget that draws speech bubbles for all active SpeechBubbles over the board. Two-pass: first collects all placements (sorted bottom-first so upward-shift decisions don't interfere with higher bubbles), then renders in reverse order (top-most first) so lower bubbles' boxes naturally cover extended tails without an explicit occlusion check.
  • Each bubble: Block::bordered() + Paragraph for the box; the tail-join on the bottom border and a column of chars from below the box down to the source object are written manually. Bubbles that would overlap are shifted upward one row at a time (up to 20 attempts) until clear.
  • place_bubble(area, obj_sx, obj_sy, lines, placed) -> Option<Rect> — sizes and places one bubble, appending its rect to placed on success.
  • board_screen_pos(area, board, bx, by) -> Option<(u16, u16)> — converts a board cell coordinate to terminal screen coordinates, returning None if off-screen. Used by bubble placement.

kiln-tui/src/log.rs — log panel widget:

  • LogState { open, height, scroll } — panel visibility, height in rows, and scroll offset. toggle(), scroll_by(delta, log_len), resize(target, total).
  • LogWidget<'a>StatefulWidget that renders all log lines newest-first in a bordered block with word-wrap.
  • logline_to_line(line) -> Line — converts a core LogLine into a styled ratatui Line; each LogSpan's optional fg/bg is applied only when present.
  • log_preview_line(logs) -> Line — builds the [l]og: <latest> preview span for the board's bottom border.

kiln-tui/src/utils.rs — shared rendering helpers:

  • rgba8_to_color(Rgba8) -> Color — lossless RGB bridge (alpha dropped).
  • rects_overlap(a, b) -> bool — true if two Rects share at least one cell; used by bubble placement.

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.

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.

# 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
[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). 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), and bump(id) functions; they read the world through Board.* (e.g. Board.player_x), check blocked(dir) -> 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), and scroll(lines). 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 typesArchetype 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 orderBoard::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 ALL_ARCHETYPES can be reordered or extended without breaking saved games. Adding a variant: the matches in behavior()/name()/default_glyph() are exhaustive (the compiler flags them), but TryFrom<&str> and ALL_ARCHETYPES are not — forget the TryFrom arm and the archetype silently loads as ErrorBlock. 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 ObjectIds (Board::objects is a BTreeMap<ObjectId, ObjectDef> with a next_object_id counter; add_object allocates), so references survive reordering. What's still missing: a remove_object, and 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 until the host is rebuilt).
  • Portals & multi-boarddone: 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 surfacingBoard::load_errors() is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup).