`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.
- 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.
- **`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.
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/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.
-`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 `content` → `fill` → `sparse`; 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 `kind` → `Resolved`: `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.
-`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.
-`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`).
-`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`).
-`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` (`Log` → `log`, `SetTile` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`, `Scroll` → `active_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.
- 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 `StdRand` per board build (in `map_file`), threaded through every generator call, so floors are deterministic/testable and depend only on map content.
-`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.
-`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 **tag** — `run` 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.
- **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.
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.
**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 in `ScrollOverlayWidget` (no ratatui primitive for "tint the whole buffer"), and 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.
- 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 calls `GameState::from_world(world)` to start on the world's designated start board.
-`run` is a ~30 FPS real-time loop: it `event::poll`s 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. When `game.active_scroll` is `Some`, all input is redirected to scroll navigation (Up/Down to scroll, letter keys for choices, Esc to dismiss if no choices); `game.tick()` is skipped until the overlay is closed.
-`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`). The scroll overlay is drawn last (above everything) by `render::draw_scroll_overlay`.
-`Ui { log: LogState, overlay: ScrollOverlayState }` — view-only panel state kept in the front-end (not on `GameState`). `Ui::tick(dt, game)` advances animations, dispatches finished close events, and ticks the game when no overlay is blocking. `begin_close(choice)` starts the close animation.
-`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.
-`SpeechBubblesWidget<'a>` — a `ratatui::widgets::Widget` that draws speech bubbles for all active `SpeechBubble`s 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.
-`ScrollAnimState { Opening(f32), Open, Closing(f32), Done }` — open/close animation progress (0.0–1.0). `Done` signals the caller to dispatch the pending choice and reset state.
-`ScrollOverlayState { offset, max_scroll, anim, pending_choice }` — threaded through render as `StatefulWidget` state. `advance_anim(dt)` ticks the animation; `close_finished()` / `take_choice()` let the caller dispatch when `Done`. `max_scroll` is an output written during render.
-`ScrollOverlayWidget<'a>` — `StatefulWidget` that dims the background, renders a bordered window at ½ width × ¾ height with animated height (forced even), wraps text, centers whitespace-leading lines, prefixes choices with `[a]`/`[b]`, and renders a scrollbar when content overflows.
-`Ui { log: LogState, overlay: ScrollOverlayState }` — aggregates all presentation state. `tick(dt, game)` advances animations, dispatches finished close events, ticks the game when no overlay blocks, and starts the open animation when a new scroll appears. `scroll_lines(delta)` adjusts `overlay.offset`. `begin_close(choice)` starts the closing animation (uses current opening progress if still animating in).
**`kiln-tui/src/input.rs`** — input dispatch:
-`InputMode { Board(bool), Scroll }` — which input mode is active; `Board(log_open)` for normal play, `Scroll` when the overlay is blocking.
-`current_input_mode(game, ui) -> InputMode` — derives mode from game + animation state.
-`handle_scroll_input(event, game, ui)` — processes Up/Down (scroll), letter keys (select choice via `resolve_choice`), Esc (dismiss if no choices), mouse wheel.
**`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/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.
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.
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.
```toml
# 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 `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)`).
- **`Behavior` and `Archetype` are separate types** — `Archetype` is the named class of a thing (`Wall`, `Empty`, `Object`); `Behavior` is its runtime properties (`solid`, `opaque`, `pushable`). Adding a new property means adding a field to `Behavior`, not a match arm at every call site.
- **Draw order is layer order** — `Board::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 `match`es 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.
- **Scripting growth** — more event hooks beyond `init`/`tick`/`bump` (e.g. `on_touch` when the player steps onto an object), a larger action/read vocabulary (more actions could carry a `time_cost`), and persisting script-local state across ticks (needs `call_fn_raw` so the per-object `Scope` isn't rewound each call).
- **Runtime spawn/destroy of objects** — objects now have stable `ObjectId`s (`Board::objects` is a `BTreeMap<ObjectId, ObjectDef>` with a `next_object_id` counter; `add_object` allocates), 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-board** — `PortalDef` is parsed but not runtime-wired. Multi-board world loading **is done**: `world::load` loads all boards as `Rc<RefCell<Board>>`, `GameState` owns the whole `World` and tracks `current_board_name`. What's still missing: a `GameState::enter_board(name)` method that updates `current_board_name` and rebuilds the `ScriptHost` for the new board's objects.
- **Load-error surfacing** — `Board::load_errors()` is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup).