Files
kiln/ARCHITECTURE.md
T
2026-06-04 23:52:33 -05:00

34 KiB
Raw Blame History

Kiln — Architecture & Design Notes

What this is

Kiln is a game-making system, not just a game. The model is ZZT (1991, Epic MegaGames) — a DOS game that shipped with a built-in editor and a simple scripting language (ZZT-OOP), which let players create and share their own worlds. The goal here is something similar: a runtime + authoring environment where game worlds are defined in plain text files with embedded scripts, and the engine interprets them.

This document records the architectural decisions made so far and the reasoning behind them, so future development sessions don't have to rediscover the "why."


Tech stack

Concern Choice Reason
Engine core kiln-core crate UI-agnostic game types + map I/O; the one crate every front-end depends on
Terminal front-end kiln-tui — ratatui 0.30 / crossterm Pure Rust TUI player; renders boards as colored text
Desktop front-end kiln-egui — eframe / egui 0.33 Pure Rust retained-mode GUI player + editor. Currently not a workspace member (see Module structure)
Scripting Rhai 1.x Pure Rust, sandboxed, WASM-compatible; designed for embedding
Map format TOML + serde Human-readable, good Rust tooling, standard in the ecosystem

WASM compatibility is a first-class requirement. Everything in the stack must compile to WASM. This ruled out Lua (C FFI via mlua/rlua) for scripting. Rhai was chosen specifically because it is pure Rust with no C dependencies and explicit no_std/WASM support.


Module structure

Cargo workspace; root members = ["kiln-core", "kiln-tui"]. kiln-egui stays in the tree but was removed from the workspace and is not built at the root.

kiln-core/src/                — the engine (UI-agnostic)
  game.rs          — core data types and game logic
  log.rs           — styled, UI-agnostic log messages (LogLine/LogSpan)
  script.rs        — Rhai scripting runtime (ScriptHost); object init/tick hooks
  map_file.rs      — TOML deserialization, Board load/save
kiln-tui/src/                 — terminal player (no editor)
  main.rs          — CLI arg + play loop; TerminalCaps detection; Kitty flags
  render.rs        — BoardWidget: draws the board into a ratatui buffer as text
  cp437.rs         — CP437 → Unicode table; renders tile indices as characters
  term.rs          — TerminalCaps detection + Kitty keyboard-protocol setup
kiln-egui/src/                — eframe/egui player + editor (NOT a workspace member)
  main.rs, render.rs, font.rs, font_dialog.rs, editor.rs, glyph_picker.rs
maps/
  start.toml       — example map; play via `cargo run -p kiln-tui -- maps/start.toml`
assets/
  vga-font-8x16.png — default CP437 bitmap font (256×128 px, 8×16 tiles); used by kiln-egui

The per-module deep-dives below are labeled by file. game.rs/map_file.rs are kiln-core; the kiln-tui player has its own section; font.rs, font_dialog.rs, and the egui main.rs/render.rs/editor.rs/glyph_picker.rs are kiln-egui (retained as design notes for when the GUI is revived).

game.rs — core types

The types here are the runtime representation of a game world. They are deliberately free of any file-loading or rendering concerns.

Glyph (Copy, Eq, Hash)
The visual representation of one cell: a tile: u32 index into the active bitmap font, a foreground color, and a background color. Stored per cell (not per element type) so individual cells can animate or vary their appearance independently — e.g. a "fire" element where each flame tile has a slightly different color — without changing their behavior. Derives Hash so the glyph picker can deduplicate the board palette into a HashSet<Glyph>.

FontSpec
Optional per-board font override: path: String, tile_w: u32, tile_h: u32. Stored on Board.font. When None, App's default_font is used. The editor's Board tab exposes a "Font…" button to change this at runtime.

Behavior
A plain data struct of runtime behavioral properties — currently passable: bool and opaque: bool. Returned by Archetype::behavior(). Adding a new property (e.g. shootable) requires only a new field here; no match arms needed at call sites. This is a deliberate improvement over storing behavior as a bag of booleans on a per-cell or per-archetype basis.

Archetype
An enum of named element types: Empty, Wall, ErrorBlock. Each variant knows:

  • Its canonical map-file name (e.g. "wall") via name()
  • Its default Behavior via behavior()
  • Its default Glyph via default_glyph() — used by the editor when stamping a cell

ErrorBlock is a sentinel for map files that reference an unknown archetype name. It renders as a yellow ? on red so malformed maps are immediately visible in-game. It is excluded from ALL_ARCHETYPES (the editor's list) because it is not a valid authoring choice.

Why Behavior and Archetype are separate types:
Archetype is the named class of a thing — it lets you say "this is a wall" in a map file and in the editor. Behavior is the runtime properties that drive simulation. Keeping them separate means adding a new property (e.g. pushable) only requires a new field on Behavior; no match arms need to be updated across the codebase.

Why Glyph and Archetype are separate:
In ZZT, each board tile had both a visual (character + color pair) and an element type. The visual could vary per-tile even for the same element type. We replicate this: Glyph is the visual (per-cell), Archetype is the class (shared across cells of the same type). This lets you have a wall that's gray in one room and blue in another without creating two archetype variants.

Board
The complete unit of a game world — one "room" or "screen" in ZZT terminology. Holds:

  • cells: Vec<(Glyph, Archetype)> — row-major grid; each cell directly owns its visual and behavioral class
  • player: Player — current player position on this board
  • objects: Vec<ObjectDef> — scripted objects; their init()/tick(dt) hooks run via the scripting runtime, and passable/opaque affect collision
  • portals: Vec<PortalDef> — exits to other boards (parsed, not yet runtime-wired)
  • scripts: HashMap<String, String> — named Rhai source, referenced by objects via script_name
  • font: Option<FontSpec> — per-board font override; None means use the app default

ObjectDef
A scripted object placed on the board. Holds x, y, glyph: Glyph (owned by the object so scripts can change its appearance), passable: bool, opaque: bool, and script_name: Option<String>. passable defaults false and opaque defaults true in map files (objects block by default). Board::is_passable checks objects before consulting the grid cell's Archetype::behavior(), so an impassable object blocks movement even when placed over a passable floor tile. The passable and opaque flags are editable via checkboxes in the editor's Objects tab.

Why cells is Vec<(Glyph, Archetype)> with no palette:
An earlier design had cells: Vec<(Glyph, usize)> where the usize indexed a per-board elements: Vec<Element> palette. This was eliminated because the palette indirection added complexity without benefit: Archetype is a Copy enum (4 bytes), so storing it per-cell is as efficient as storing an index, and it removes the invariant that the index must stay in sync with a specific board's palette.

Why Board is the complete unit (no wrapper struct):
An earlier design had GameMap { board: Board, player: Player, ... }. This was eliminated because the split was artificial: there's no meaningful use of a Board without a player position, and no meaningful use of a player without a Board. ZZT itself treats a board as containing everything — the grid, the objects, and the player entry point. Collapsing to a single struct matches the domain model.

BoardState / GameState
BoardState is the scriptable world — currently just { board: Board }, with room for runtime-only state (e.g. a shared variable blackboard). GameState holds it behind an Rc<RefCell<BoardState>>, alongside the message log and a ScriptHost. Keeping the world a sibling of the script host (rather than owned by it) is the key ownership move: host functions capture a clone of that Rc — a 'static handle to a different RefCell than the running engine — so they can read/queue writes without aliasing the borrow that's executing scripts. Front-ends and internal logic reach the board only through board() -> Ref<Board> / board_mut() -> RefMut<Board> (no pub board). try_move moves the player; run_init() / tick(dt) run object hooks; apply_commands() drains the script command queue after each batch — the deferred apply is when &mut is finally free of any outstanding borrow.


log.rs — styled messages

LogLine { spans: Vec<LogSpan> }, where LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> }, is a UI-agnostic styled message: colors are core Rgba8, not a front-end type, so the engine stays UI-free. LogLine::raw(), a chainable push(), and append() build messages; each front-end converts a LogLine to its own styled text at render time (kiln-tui's render::logline_to_line). The game log lives on GameState; both the engine (scripts) and front-ends append to it.

script.rs — Rhai scripting runtime

ScriptHost owns the Rhai Engine, the scripts referenced by a board's objects (compiled once per name via Engine::compile), a persistent per-object Scope, and a shared command queue. Built with ScriptHost::new(&Rc<RefCell<BoardState>>), which registers the API, compiles scripts, and records each object's available hooks (detected with AST::iter_functions() by name/arity) but runs nothing — compile/unknown-script failures are queued as GameCommand::Error.

Two optional lifecycle hooks per object: init() (zero-arg, run once) and tick(dt) (elapsed seconds as f64, run per frame), driven by run_init() / run_tick(dt). Runtime errors become Error commands, not fatal.

Reads vs writes. Scripts read the world directly: a read-only BoardView (a registered type with getters only — read-only by construction) is pushed into each scope as board, so a script writes board.player_x; the getter briefly borrows the shared BoardState. Scripts write by enqueuing Command { source, kind } where kind: GameCommand is Move(Direction) / SetTile(u32) / Log(LogLine) / Error(String). The host fns move/set_tile/log push into a shared Rc<RefCell<Vec<Command>>> cloned into each closure (the same 'static-closure trick the old log sink used, generalized); GameState::apply_commands drains and applies them after the batch. Reads-are-live, writes-are-deferred gives frame-coherent semantics and keeps script execution free of any &mut GameState borrow.

Sender identity (which object issued a command) rides Rhai's per-call tag: run calls call_fn_with_options(...with_tag(object_index)...) and the host fns read it via NativeCallContext::tag(), so scripts write move(north) without a this argument (north/south/east/west are Direction constants in scope; impl From<Direction> for (i32,i32) yields the delta). The object's identity is currently its index into Board::objects — a stopgap (a // TODO flags replacing it with stable unique ids that survive spawn/destroy). Default call_fn rewinds the scope per call, so script-local persistence across ticks is still future work; the per-object Scope is the seam for that. Engine/Scope are not Send — fine for the single-threaded kiln-tui.


kiln-tui — terminal player

kiln-tui is a play-only front-end (no editor yet): it takes a map-file path on the command line, loads a Board via kiln_core::map_file::load, and lets the player walk around with the arrow keys (or hjkl). It depends only on kiln-core, ratatui/crossterm, and the color crate — none of the egui stack. This is the proof that kiln-core is genuinely UI-agnostic: the engine needed zero changes to add a second front-end.

Text rendering instead of bitmap tiles.
A terminal can't blit a bitmap font, so kiln-tui reinterprets each Glyph.tile index as a character. The default kiln font is CP437, where the tile index equals the code point, so cp437::tile_to_char maps indices through a 256-entry CP437→Unicode table (graphic glyphs for 0x000x1F, box-drawing for 0xB00xDF, ASCII in between). The [font] map section is still parsed and stored on Board, but kiln-tui ignores the image path and uses the tile indices directly. Glyph.fg/bg (Rgba8) map to ratatui::style::Color::Rgb — full 24-bit truecolor, alpha dropped (256-color terminals approximate it).

render.rsBoardWidget.
A ratatui::widgets::Widget that draws the board into the frame buffer. The axis(board_len, view, player) -> (offset, pad, count) helper resolves each axis independently: a board smaller than the viewport is centered with screen padding; a larger one scrolls to keep the player visible, clamped so no empty margin shows past the board edge. Per cell, glyph priority is player > object > grid floor.

term.rs — capability detection + Kitty protocol.
TerminalCaps { keyboard_enhancement, truecolor } is detected once at startup: keyboard_enhancement via crossterm's supports_keyboard_enhancement() (a real query/response with the terminal), truecolor from $COLORTERM. When keyboard enhancement is supported, kiln-tui pushes the Kitty keyboard-protocol flags (DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES | REPORT_ALTERNATE_KEYS | REPORT_ALL_KEYS_AS_ESCAPE_CODES) after ratatui::init() and pops them before restore(). This unlocks richer input — modifier-only keypresses, key release/repeat events, unambiguous Ctrl combos — which the engine intends to hand to scripts so they can choose bindings the terminal actually supports.

Real-time loop.
The loop runs at ~30 FPS: it event::polls only until the next frame deadline, so input stays responsive, and otherwise wakes to advance the game by the real elapsed dt via GameState::tick (which drives object tick(dt) hooks). Because REPORT_EVENT_TYPES makes the terminal emit Release (and Repeat) events, it acts on Press and Repeat — so holding a key moves continuously at the terminal's own repeat cadence — and skips Release, which would otherwise fire a second, spurious move. Mouse capture is enabled so the log panel can be scrolled (wheel) and resized (drag the divider). game.run_init() runs object init() hooks after the board loads and before the loop.

Log panel + capability line.
The bottom-left border previews the latest log line after a [l]og: label; pressing l opens a bottom panel listing the log newest-first (render::logline_to_line converts each LogLine). TerminalCaps::status_logline() returns a LogLine (not a border badge) seeded into the log at startup: when truecolor is available it draws "truecolor" with each letter a different rainbow color (hue_to_rgb, a full-saturation HSV→RGB helper), else a plain "256-color", followed by the input descriptor.

Why a capability struct, not a terminal name.
Scripts should branch on what works (truecolor, keyboard enhancement), not on a brittle terminal brand string that is often unset over SSH or inside tmux. TerminalCaps deliberately carries no program-identity field.


font.rs — bitmap font

BitmapFont wraps an egui TextureHandle plus tile geometry (tile_w, tile_h, img_w, img_h). The texture is preprocessed at load time: the top-left pixel of the source PNG becomes the "background" sentinel — those pixels are stored as TRANSPARENT, all others as opaque WHITE. This lets egui's image tinting do all the colorization work at render time:

  1. painter.rect_filled(cell_rect, 0.0, glyph.bg) — paint the background
  2. painter.image(texture_id, cell_rect, tile_uv, glyph.fg) — draw the tile; egui multiplies each pixel by the tint color, so white → fg and transparent → nothing (revealing bg)

No custom shaders required.

tile_uv(tile) delegates to the private compute_tile_uv function which is unit-tested directly (no egui Context needed). tile_cols(), tile_count(), img_w(), img_h() are helpers used by the picker and dialog to lay out grids.

create_placeholder(ctx) generates a 16×16 grid of 8×8 tiles where each tile shows the 8 bits of its index as a column bar. Used as a fallback when assets/vga-font-8x16.png is missing.


font_dialog.rs — font picker dialog

FontDialogState holds the in-progress edits: path, tile_w, tile_h, and a preview: Option<BitmapFont> loaded when the user clicks "Load". Separating dialog state from FontSpec means the user can explore settings without committing.

show(ctx, open, state, font_spec) renders a floating window with:

  • A text field + "Browse…" button (rfd::FileDialog)
  • DragValue controls for tile dimensions
  • A "Load" button that decodes the PNG and stores it in state.preview
  • A scrollable preview of the full tilesheet with grid overlay at tile boundaries
  • "Apply" (enabled only after a successful Load) writes to font_spec and closes; "Use default" clears font_spec

Uses a should_close: bool flag outside the closure to work around egui's .open(open) borrow conflict.


map_file.rs — file loading

MapFile and supporting structs are serde deserialization types only — they exist solely to parse TOML and are never used at runtime.

TileIndex is a #[serde(untagged)] enum (Num(u32) | Chr(char)) that accepts either an integer (tile = 35) or a single-character string (tile = "#") in palette entries. This preserves backward compatibility with existing map files while allowing numeric indices in new ones.

FontHeader deserializes the optional [font] section and is converted to FontSpec in From<MapFile> for Board.

impl From<MapFile> for Board — the single conversion point. Reads the palette (each entry maps a character to a (Glyph, Archetype) pair), then walks the grid string character-by-character to build cells. Unknown archetype names produce an ErrorBlock cell and log a warning. This is the only place that knows about both the file format and the runtime representation.

pub fn load(path: &str) -> Result<Board, ...> — reads a file, deserializes, converts. Called from main() before the window is created.

Why loading happens before window creation:
eframe requires NativeOptions (including window size) to be set before calling run_native. Loading the board first means its dimensions are available if they're ever needed for sizing logic (currently the window uses fixed defaults, but the board must exist before App::new is called).


main.rs — app + frame loop

App holds a GameState, an AppMode (Play | Edit), an EditorState, a default_font: BitmapFont (loaded from assets/vga-font-8x16.png at startup, falling back to create_placeholder), and a board_font: Option<BitmapFont> (loaded from board.font when a per-board font is set). App::new takes &egui::Context to upload textures before the first frame.

Font change detection: before calling show_editor_panel, font_spec_before is cloned; after the call, if board.font differs, apply_font_spec reloads board_font. App::active_font() resolves board_font.as_ref().unwrap_or(&default_font); at the editor-panel and glyph-picker sites the same expression is used inline instead so the font borrow stays disjoint from the &mut editor/&mut board borrows (a method call would borrow all of self).

update is a thin sequence of phase methods, each owning one concern:

  1. handle_input — table-driven arrow keys → GameState::try_move (Play mode only)
  2. menu_bar — File menu (Save/Save As via save_to/save_as, Exit) and the Play/Edit toggle
  3. try_show_script_editor — if a script is open, fills the CentralPanel with the code editor and returns true, ending the frame early
  4. Editor side panel (Edit mode) — editor::show_editor_panel, declared before CentralPanel; then the font-reload check
  5. show_board — draws the viewport and returns the clicked cell; the click is dispatched to handle_board_click after it returns
  6. show_glyph_pickers — the Palette and Objects glyph pickers; then the selected object's glyph is flushed back to board.objects[i]

Click dispatch borrow split: show_board(&self) only draws and returns Option<(usize, usize)> (validated cell coords). The mutating handle_board_click(&mut self, cx, cy) runs afterward, so the immutable font borrow held during drawing never conflicts with the &mut self handler.

Viewport — Play mode:
render::board_origin(available, board_w, board_h, player, font, zoom) computes the pixel position of cell (0,0). If the board fits along an axis it is centered; if it overflows the viewport is centered on the player and clamped so no empty space appears at the board edges. No scroll bars.

Viewport — Edit mode:
The board is wrapped in egui::ScrollArea::new([true, true]), which shows scroll bars when the board overflows the viewport. Content is allocated at exact board size; rect.min from allocate_exact_size serves as the origin passed to render::draw_board.

Stamp action: *board.get_mut(cx, cy) = (self.editor.palette.glyph, self.editor.palette.selected) — the custom glyph and selected archetype are written together. The glyph can differ from the archetype's default_glyph() if the user has customized it.


render.rs — drawing primitives

All pixel-level rendering knowledge lives here. No dependency on EditorState or App. Cell size is no longer a fixed constant — it comes from the active BitmapFont.

Constants: Window sizing (DEFAULT_WINDOW_W = 840, DEFAULT_WINDOW_H = 524, MIN_WINDOW_W/H) defined here. No CELL_W/CELL_H — those were removed when bitmap fonts were introduced. Per-cell pixel size comes from BitmapFont::cell_size(zoom).

rgba8_to_color32(c) / color32_to_rgba8(c) — inverse conversions between the core color::Rgba8 and egui's Color32, used at the glyph-picker boundary where colors cross between the two type systems.

paint_glyph(painter, rect, glyph, font) — fills rect with glyph.bg, then draws the tile image tinted by glyph.fg. Used by draw_glyph, glyph_preview_button, and glyph_picker.

glyph_preview_button(ui, glyph, font) -> Response — allocates a one-cell swatch (unzoomed), paints glyph, and returns the click Response. Shared by the Palette and Objects editor tabs for their editable glyph swatches.

draw_glyph(painter, origin, x, y, glyph, font, zoom) — computes the cell rect from font.cell_size(zoom), delegates to paint_glyph.

draw_board(painter, origin, board, font, zoom) — iterates all cells calling draw_glyph, then draws objects and the player as overlays on top.

draw_object_overlays(painter, origin, board, font, selected, zoom) — called from Edit mode when the Objects tab is active. Draws a dim border on every object cell and a bright border on the selected one, making objects discoverable without obscuring the underlying tile.

pos_to_cell(origin, pos, tile_w, tile_h) -> (i32, i32) — converts a pixel position to cell coordinates using floor division. Returns negative values for clicks above/left of the origin; callers must guard >= 0. Mirrors the rendering math so clicks land on the correct cell.

board_origin(available, board_w, board_h, player, font, zoom) -> Pos2 — computes the pixel origin for Play-mode rendering (centering or player-tracking with edge clamping).


editor.rs — editor state and side panel

EditorTab (Palette | Objects | Board | Scripts | World) — which tab is active in the side panel.

EditorState — transient editor state, grouped by concern into sub-structs so each tab can take only the borrow it needs (the dispatch in show_editor_panel passes disjoint sub-struct borrows, e.g. &mut editor.objects and &mut editor.scripts):

  • tab: EditorTab — which side-panel tab is active
  • palette: PaletteStateselected: Archetype (class to stamp), glyph: Glyph (visual to stamp; resets to archetype.default_glyph() when the archetype changes), picker_open: bool
  • font_dialog: FontDialogopen: bool, state: FontDialogState
  • objects: ObjectEditselected: Option<usize> (index into board.objects), picker_open: bool, editing_glyph: Glyph (local copy written back to board.objects[i].glyph each frame), placing: bool (next board click creates a new ObjectDef)
  • scripts: ScriptEditediting: Option<String> (script open in the code editor), content: String (working copy), new_name: String (new-script draft)

show_editor_panel(ctx, editor, board, active_font) — renders the resizable right-side panel (default 200 px): a tab bar, then dispatch to one function per tab. palette_tab: scrollable archetype list and glyph preview button. objects_tab: "Add Object" placement toggle, click-to-select, and for the selected object a glyph preview, Passable/Opaque checkboxes (writing directly to board.objects[i]), and a script combobox. board_tab: font path label + "Font…" button + zoom slider. scripts_tab: create and edit named scripts. World tab: placeholder. The glyph preview swatches use render::glyph_preview_button.


glyph_picker.rs — glyph picker dialog

show(ctx, open, glyph, board_cells, font) — renders a floating egui::Window. Takes open: &mut bool and glyph: &mut Glyph directly, with no dependency on EditorState, so it can be invoked from any context that needs glyph selection.

Three sections:

  1. Board palette — unique glyphs found on the current board (deduplicated via HashSet<Glyph>), shown as clickable cells rendered with paint_glyph. The current glyph is highlighted with a white stroke.
  2. Colorscolor_edit_button_srgba pickers for glyph.fg and glyph.bg.
  3. Tile grid — all tiles from the active BitmapFont, displayed at tile_w × tile_h per cell in a scrollable area. Column count matches the font image layout. Clicking a cell updates glyph.tile. The current tile is highlighted.

Map file format

XPM-inspired (XPM is an old X11 image format that uses a character palette to define pixel colors). A [palette] section maps single characters to both a Glyph (visual) and an Element (behavior). The [grid] content is a TOML multi-line string where each character is a palette key.

Keep this example in sync with map_file.rs whenever the format changes.

[map]
name = "Room Name"
width = 60
height = 25
player_start = [30, 12]

# Optional: override the default font for this board.
[font]
path = "assets/my_font.png"
tile_w = 8
tile_h = 16

[palette]
" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
"#" = { archetype = "wall",  tile = 35,  fg = "#808080", bg = "#606060" }

[grid]
content = """
############################################################
#                                                          #
############################################################
"""

[[objects]]
x = 10
y = 5
tile = "#"
fg = "#aa3333"
bg = "#000000"
passable = true
script_name = "greeter"

[[portals]]
x = 59
y = 12
target_map = "cave"
target_entry = "west_door"

[scripts]
greeter = """
fn init() {
    log(`player at ${board.player_x}, ${board.player_y}`);  # read
    set_tile(2);                                            # write
}
fn tick(dt) { move(north); }   # dt = elapsed seconds; dir ∈ north/south/east/west
"""

Why TOML over a custom format:
The toml crate gives us deserialization with minimal code. Multi-line strings for the grid give a visual representation of the map. Embedded Rhai scripts fit naturally in TOML multi-line strings without escaping issues.

Why tile accepts both char and integer:
TileIndex is a #[serde(untagged)] enum. Old map files using tile = " " (a single-character string) continue to work unchanged. New map files can use tile = 32 (integer) for clarity or when the tile has no printable character equivalent.

Why archetypes by name, not by property:
Palette entries used to store passable = true/false directly. Switching to archetype = "wall" means adding a new behavioral property (e.g. opaque) only requires a code change — existing map files don't need to be updated. It also makes files more readable: archetype = "wall" is self-documenting. Unknown names produce a visible ErrorBlock cell rather than silently defaulting.

Why the palette approach:
A direct mapping from palette character → (Glyph, Archetype) means the map file is both human-readable (you can see the shape of the room from the grid string) and flexible (per-cell visual variation is possible by using different palette chars with the same archetype but different colors).

Colors are "#RRGGBB" hex strings — universally understood, hand-editable.

player_start is a header field, not a palette character. The player is not a board cell; they are an entity that moves over the board. Using a palette character for player start (like @ in many roguelikes) would mean the tile under the player is always that character, which makes it awkward to place a player over different terrain.


What's not yet implemented

Object scripting — the runtime exists (script.rs): objects run optional init()/tick(dt) hooks, read the world via board.*, and write via queued commands (move, set_tile, log). Still to come:

  1. More event hooks beyond lifecycle — e.g. fire on_touch when the player moves onto an object's cell
  2. Grow the command vocabulary and read API (send_message, randomness, board queries, more glyph setters)
  3. Stable object idsCommand.source / ObjectRuntime.object_index are array indices into Board::objects, which break under spawn/destroy/reorder; replace with monotonically-increasing unique ids and key objects by them (a // TODO marks this in code)
  4. Persist script-local state across ticks (needs call_fn_raw so the per-object Scope isn't rewound each call)
  5. PortalDef is still parsed-only — no multi-board navigation yet

Object behavior from scriptsObjectDef.passable and ObjectDef.opaque are live and editable in the editor, but they are static author-set values. Eventually they should be overridable by the object's Rhai script at runtime (e.g. a door script that flips passable when opened). Board::is_passable already has the right shape for this; the script runtime just needs to be wired in.

Portal navigationPortalDef stores a target_map and target_entry but there's no multi-board loading or board switching yet.

Multi-board world — right now the engine loads a single maps/start.toml. Future: a world file or directory of boards, lazy-loaded as the player moves through portals.

File open dialog — the editor has no way to load a different map file from the UI. rfd is already a dependency (used by the font dialog), so a "Open map…" button is straightforward to add. WASM support for rfd file dialogs is an open question.

Map save — the editor can paint cells but has no way to write changes back to a .toml file. Saving will require serializing the Board back to MapFile format and writing it to disk.

Terminal editorkiln-tui is play-only; it has no editing UI. The editor currently lives only in kiln-egui, which is out of the workspace. A future TUI editor (or reviving the egui one) is an open direction.


Future considerations

These are not current requirements but intended future directions. Where a planned change conflicts with current design, the tension is called out explicitly so it can be addressed before it becomes a problem.

The player may become an object; boards may have no player

The long-term goal is for the "player" to be an object on the board that happens to respond to arrow key events — not a hardcoded special entity. Some boards may have no player-like object at all and do something else with input events (a cutscene, a menu, a puzzle that reacts to keys differently).

ZZT hard-coded the player as a special element and many game authors had to work around this limitation (e.g. hiding the real player behind a wall and scripting a fake one). This is a deliberate improvement over that model.

Current design tension:

The following assumptions are baked in today and will need to change when this is implemented:

  • Board.player: Player is a required non-optional field. A board with no player can't be represented. This should eventually become Option<Player>, or the player should be removed from Board entirely and tracked by the engine layer only when present.

  • player_start in the map file is a required header field. It will need to become optional, or player spawning will move into the object/script system (an object with a special role, spawned at its x/y position).

  • GameState::try_move directly mutates board.player. Once the player is an object driven by Rhai, movement will go through the scripting dispatch layer instead. try_move will likely be replaced by something like engine.dispatch_event(ArrowKey(dx, dy)).

  • In main.rs, the player is rendered as a hardcoded overlay using Glyph::player(). Once the player is an object, it should be rendered as part of the normal object layer, not as a special case.

None of these are blockers for current work, but avoid making Board.player more central than it already is (e.g. don't add methods that assume player presence, don't derive window sizing from player position).


ZZT reference

ZZT (1991) was a text-mode game for DOS. Its playfield was 60×25 characters (the right 20 columns were the stats panel). Each board was a self-contained screen with objects (tiles with embedded ZZT-OOP scripts), passageways to adjacent boards, and a fixed element type system (about 50 built-in element types). Players could create worlds with the built-in editor and share .ZZT files.

Kiln takes the core ideas — tile-based boards, embedded scripts per object, named portals between boards — and rebuilds them in a modern, WASM-capable stack with a more flexible scripting language and a human-readable file format.