`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.
- **`kiln-egui`** — the original eframe/egui desktop app (player + editor). **Still in the tree but no longer a workspace member**: it was removed from the root `Cargo.toml``members` and its files were left untouched, so it is not built at the root and is not guaranteed to compile against the current engine. Its notes below are retained for when it is revived.
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`, `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. `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`).
-`ObjectDef` — a scripted tile: `x`, `y`, `glyph: Glyph`, `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `script_name: Option<String>`, `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. `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black.
-`glyph_at(x, y) -> Glyph` — the glyph a renderer should display at `(x, y)`. Priority: player > solid object > non-solid object with nonzero tile > non-Empty grid archetype > floor. **Includes the player** — `glyph_at` at the player's cell returns `Glyph::player()`. An `Empty` cell's own glyph is always ignored (floor supersedes it). 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 grid archetype).
-`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 `Empty` behind.
-`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).
-`GameState` — holds `board: Rc<RefCell<Board>>`, `log: Vec<LogLine>`, `scripts: ScriptHost`, and `pub speech_bubbles: Vec<SpeechBubble>`. Front-ends reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>`. `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 `apply_commands()`, which drains the board queue and applies each `Action` (`Log` → `log`, `SetTile` → source glyph, `Move(dir)` → `step_object`, `Say` → `speech_bubbles`). 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`.
**`kiln-core/src/floor.rs`** — the visual floor layer:
- The floor is a cosmetic per-cell background drawn wherever a cell would render as `Archetype::Empty` (it is *how*`Empty` is drawn; the cell's own `Empty` glyph is ignored). Computed once at load and cached on `Board::floor`, so there is no per-frame cost / flicker; the raw `FloorSpec` is also kept (`Board::floor_spec`) so `map_file::save` round-trips it.
-`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) seeded with a fixed `FLOOR_SEED` for deterministic, testable output.
-`FloorSpec` — `#[serde(untagged)]` enum for the `[floor]` declaration: `Generator(String)` (whole board), `Single(FloorGlyph)` (one fixed glyph everywhere), or `Grid { content, palette: HashMap<String, FloorTile> }` (a grid with its own palette). Untagged ordering matters: a string → `Generator`; a table with `tile` → `Single`; a table with `content` → `Grid`. `FloorTile` (untagged) is a grid-palette entry: a generator name (string) or a `FloorGlyph` (table). `FloorGlyph { tile: TileIndex, fg, bg }` reuses `map_file::TileIndex` (now `pub`) and `parse_color`.
-`build_floor(&Option<FloorSpec>, w, h, &mut Vec<LogLine>) -> Vec<Glyph>` — best-effort/nonfatal (like the map loader): unknown generators/chars and grid-dimension mismatches are recorded on the error vec and that cell falls back to the canonical black-on-black space; `None` → an all-empty floor (the historical look).
-`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 (compiled once per name), a per-object persistent `Scope` + output queue + ready timer, and the shared board queue. Built with `ScriptHost::new(&Rc<RefCell<Board>>)` (registers the API, compiles scripts, 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)` append an `Action` (`Move`/`SetTile`/`Log`/`Say`; `Action::time_cost()` is `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>`). `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()` and `Queue.clear()`. 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.
-`PlayerStart` — `#[serde(untagged)]` enum (`Coord([i32;2])` | `Char(char)`); `player_start = [x, y]` or `player_start = "@"` (locate that char in the grid). Same dual-form trick as `TileIndex`
-`impl TryFrom<MapFile> for Board` — the single load conversion. **Best-effort/nonfatal**: only a grid-dimension mismatch returns `Err`; every other problem (unknown archetype/grid char → `ErrorBlock`; object/player placement-char issues; one-solid-per-cell conflicts) is recovered and recorded on `Board::load_errors` (see `Board::is_valid`). Resolves the player (coord or `@` char, first-occurrence, falling back to `(0,0)`) and lets it *win* its cell; places objects by `x`/`y` or `palette` char
-`pub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>>` — reads and converts a `.toml` map file
-`pub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>>` — serializes `Board` back to TOML and writes to disk
### kiln-tui modules
The terminal player. Renders the board as text: each `Glyph.tile` index is reinterpreted as a character (the default kiln font is CP437, where the tile index equals the code point) and drawn with the glyph's RGB colors. No bitmap font or UV mapping is involved.
**`kiln-tui/src/main.rs`** — entry point and play loop:
- Parses a single positional arg (the map path); prints usage and exits non-zero if missing. Loads the board via `kiln_core::map_file::load`*before* touching the terminal so load errors print to a normal screen.
-`ratatui::init()` → detect `TerminalCaps` → push Kitty flags when supported → `run` loop → pop Kitty flags → `ratatui::restore()`.
-`run` is a ~30 FPS real-time loop: it `event::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.
-`draw` wraps the board in a bordered `Block` titled with the board name and controls. When the log panel is closed, the latest log line is previewed in the bottom-left border after a `[l]og:` label; when open, a bottom panel lists the log newest-first (`render::logline_to_line` converts each `LogLine`).
-`Ui { log_open, log_height, scroll }` — view-only panel state kept in the front-end (not on `GameState`).
-`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_screen_pos(area, board, bx, by) -> Option<(u16, u16)>` — converts a board cell coordinate to terminal screen coordinates, returning `None` if the cell is scrolled off-screen. Used by the speech-bubble overlay.
-`draw_speech_bubbles(buf, area, board, bubbles)` — draws box-drawing speech bubbles over the board for all active `SpeechBubble`s. Each bubble shows a `╭─╮` / `│ text │` / `╰─╮─╯` / `│` frame with the tail at the object's column. Bubbles that would overlap are shifted upward one row at a time until no collision.
**`kiln-tui/src/term.rs`** — terminal capability detection and Kitty setup:
-`TerminalCaps { keyboard_enhancement: bool, truecolor: bool }` — detected once at startup. `keyboard_enhancement` is a real query/response (`supports_keyboard_enhancement()`); `truecolor` reads `$COLORTERM`. Intended to be handed to scripts so they can pick bindings the terminal actually supports.
-`summary_line() -> Line` — styled one-line badge for the bottom border; when `truecolor`, the word "truecolor" is drawn with each letter a different rainbow color (`hue_to_rgb`, an HSV→RGB helper), else a plain "256-color".
-`push_kitty_flags()` / `pop_kitty_flags()` — enable/disable the Kitty keyboard protocol (`DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES | REPORT_ALTERNATE_KEYS | REPORT_ALL_KEYS_AS_ESCAPE_CODES`); the last flag makes modifier-only keypresses observable. Pushed only when supported, popped before restore. Enabling `REPORT_EVENT_TYPES` is why the input loop must filter out `Release` events.
### kiln-egui modules (no longer a workspace member)
**`kiln-egui/src/font.rs`** — bitmap font loading and UV mapping:
-`BitmapFont` — wraps an egui `TextureHandle` (preprocessed to opaque-white / transparent) plus tile dimensions. The top-left pixel of the source PNG defines the "background" color; those pixels become `TRANSPARENT`, all others become `WHITE`. At render time, `painter.image(…, tile_uv, fg_color)` tints white pixels to `fg` and transparent pixels reveal the `bg` fill behind.
-`BitmapFont::load(ctx, path, tile_w, tile_h)` — loads from disk
-`BitmapFont::from_bytes(ctx, bytes, tile_w, tile_h, label)` — loads from embedded bytes
-`BitmapFont::create_placeholder(ctx)` — 16×16 grid of 8×8 procedural tiles (bit-pattern bars), used as fallback when no font file is present
-`BitmapFont::tile_uv(tile)` — returns a UV `Rect` for the given tile index (left-to-right, top-to-bottom). Unit-tested via `compute_tile_uv`.
-`BitmapFont::cell_size(zoom)` — pixel size (`Vec2`) of one rendered cell at the given integer zoom; centralizes the `tile_w * zoom` math used across rendering and hit-testing
-`FontDialogState::from_spec(spec)` — initializes from an existing `FontSpec` or defaults (8×16, empty path)
-`show(ctx, open, state, font_spec)` — floating dialog with file browser (`rfd::FileDialog`), tile dimension `DragValue` controls, scrollable tilesheet preview with grid overlay, and Apply / Use default buttons. Uses a `should_close` flag to avoid the egui `.open()` borrow conflict.
-`App` holds `GameState`, `AppMode`, `EditorState`, `default_font: BitmapFont`, and `board_font: Option<BitmapFont>`
-`App::new(board, egui_ctx)` — loads `assets/vga-font-8x16.png` as the default font (falls back to `create_placeholder`); loads per-board font from `board.font` if present
-`App::update` is a thin sequence of phase methods: `handle_input` (table-driven arrow keys, Play only) → `menu_bar` (File Save/Save As via `save_to`/`save_as`, Exit, mode toggle) → `try_show_script_editor` (returns `true` to take over the frame) → editor side panel + font reload → `show_board` → `handle_board_click` → `show_glyph_pickers` → object-glyph writeback
-`App::active_font()` — resolves the per-board font or the default; used wherever an exclusive borrow of `self` is not also needed
-`App::show_board(&self) -> Option<(usize, usize)>` — draws the viewport and returns the clicked cell (Edit mode); the click is dispatched to `handle_board_click(&mut self, cx, cy)`*after*`show_board` returns, so the `&mut` handler never conflicts with the font borrow
- Borrow split: at the editor-panel and glyph-picker call sites, `self.board_font.as_ref().unwrap_or(&self.default_font)` is used inline (not `active_font()`) so the font borrow stays disjoint from the `&mut self.editor`/`&mut board` borrows
- Window sizing constants (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`, `MIN_WINDOW_W/H`); no fixed `CELL_W/H` — cell size comes from the active `BitmapFont`
-`glyph_preview_button(ui, glyph, font) -> Response` — allocates a one-cell swatch (unzoomed), paints `glyph`, returns the click response; used by the Palette and Objects tabs
-`draw_glyph(painter, origin, x, y, glyph, font, zoom)` — sizes cell via `font.cell_size(zoom)`, delegates to `paint_glyph`
-`draw_board(painter, origin, board, font, zoom)` — draws all cells then player overlay
-`draw_object_overlays(painter, origin, board, font, selected, zoom)` — highlights all object cells in the Objects editor tab; the selected object gets a brighter border
-`board_origin(available, board_w, board_h, player, font, zoom)` — centers board or clamps to player with no empty space
-`show_editor_panel(ctx, editor, board, active_font)` — resizable right-side panel (default 200 px); renders the tab bar then dispatches to a per-tab function (`palette_tab`, `objects_tab`, `board_tab`, `scripts_tab`; World is empty), passing the relevant sub-struct(s). Palette shows the archetype list and glyph preview button; Board shows the font path, "Font…" button, and zoom slider; Objects lists the selected object's glyph preview, **Passable/Opaque checkboxes**, and script combobox; Scripts creates/edits named scripts
-`show(ctx, open, glyph, board_cells, font)` — takes `open: &mut bool` and `glyph: &mut Glyph` directly; no dependency on `EditorState`
- Three sections: board palette (unique glyphs deduped via `HashSet<Glyph>`, click to select), FG/BG color pickers, scrollable tile grid (all tiles in the active font at `tile_w × tile_h` per cell)
XPM-inspired: a `[palette]` maps single characters to `(Glyph, Archetype)` definitions; `[grid] content` is a TOML multi-line string where each character indexes the palette.
The optional `[floor]` section declares the visual floor layer (drawn under every empty cell, and revealed when a crate/object is pushed away); see `floor.rs`. It takes one of four forms: omitted (black-on-black space, the default), a generator name (`floor = "grass"`), a single fixed glyph (`[floor]` with `tile`/`fg`/`bg`), or a grid (`[floor] content` + `[floor.palette]`, whose entries are generator names or fixed glyphs). The three built-in generators are `grass`/`dirt`/`stone`. Floor parsing is best-effort: an unknown generator/char or a grid-dimension mismatch is recorded on `Board::load_errors` and those cells fall back to the default empty glyph.
Colors are `"#RRGGBB"` hex strings. `player_start` accepts either `[x, y]` coordinates **or** a single grid char to locate (convention `"@"`; that cell loads as `Empty`, like an object placeholder). The `tile` field accepts either a single-character string (`tile = " "`) or an integer (`tile = 35`); both are valid and existing char-style map files continue to work. The grid multi-line string's leading newline is trimmed by TOML; trailing newline is handled correctly by `str::lines()`. Unknown archetype names produce an `ErrorBlock` cell and a logged warning, as does any grid character that is neither a `[palette]` key nor an object/player `palette` char. Objects may be placed by `x`/`y` or by a single grid `palette` char (uppercase by convention). **Loading is best-effort and nonfatal** (only a grid-dimension mismatch is a hard error): a placement char that appears multiple times uses the first occurrence, a missing object char drops that object, a missing player char falls back to `(0, 0)`, and one-solid-per-cell conflicts drop the later solid — each problem is recorded on `Board` (see `is_valid()` / `load_errors()`). The **player joins one-solid-per-cell** and *wins* its cell: it is placed first, silently clearing solid terrain under it and dropping any solid object that lands there. Script source lives in the `[scripts]` table (name → Rhai source); 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)` (`dir` ∈ `North`/`South`/`East`/`West`), `set_tile(n)`, `log(s)`, and `say(s)` (displays a speech bubble above the object for 3 s, replacing any existing bubble from that object). Writes don't take effect immediately: each is queued and **at most one `move` resolves per 250 ms** per object (a blocked move still costs the cooldown), so objects can't act infinitely fast. When two objects move into the same cell on one tick, **lowest id wins** (= load order; the earlier-loaded object lands, the later is pushed or blocked) and the object that was moved into receives `bump(id)`.
- **`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.
- **The floor layer is how `Empty` is drawn** — `Board::glyph_at` returns the per-cell `floor` glyph for any `Empty` cell, so an `Empty` cell's *own* glyph is ignored (and a cell vacated by a pushed crate automatically shows the floor with no movement-code changes). The floor is computed once at load and cached (`Board::floor`); the raw `FloorSpec` is kept (`Board::floor_spec`) only so saves round-trip. One consequence: a map that colored an `Empty` cell's bg no longer shows it — empties always come from the floor layer (default black-on-black). See `floor.rs`.
- **One solid per cell** — at most one solid entity (the player, a solid grid archetype, *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 solid object landing on an already-solid cell is dropped (recorded via `report_error`); the player *wins* its cell — its resolved position (including the `(0, 0)` fallback when `player_start` is unresolvable) silently clears any solid terrain under it and drops a conflicting object. `Board::solid_at` relies on this invariant.
- **`cells: Vec<(Glyph, Archetype)>`** — each cell owns its visual and behavioral class directly; there is no per-board element palette or integer indirection. `Archetype` is `Copy` so this is efficient.
- **Archetypes are referenced by name in map files** — so `ALL_ARCHETYPES` can be reordered or extended without breaking saved games. 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.
### 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).
-`player_start` is effectively required; player spawning should eventually move into the object/script system.
-`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; there is no multi-board world/loading yet (the engine loads a single map).
- **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).