Files
kiln/CLAUDE.md
T
2026-06-08 22:15:44 -05:00

39 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project

kiln is a ZZT-inspired game-making system written in Rust (edition 2024) — a runtime and authoring environment where game worlds are plain-text files with embedded scripts. The model is ZZT (1991, Epic MegaGames): a DOS game that shipped with a built-in editor and a scripting language (ZZT-OOP), letting players create and share worlds. ZZT's playfield was 60×25 characters; each board was a self-contained screen with scripted objects, passages to adjacent boards, and ~50 built-in element types.

Tech-stack rationale (the "why"):

  • WASM compatibility is a first-class requirement — everything in the stack must compile to WASM. This is why scripting uses Rhai (pure Rust, sandboxed, no C deps, explicit WASM support) rather than Lua (which needs C FFI via mlua/rlua).
  • Maps are TOML + serde — human-readable, hand-editable, good Rust tooling; grid multi-line strings make the room shape visible, and embedded Rhai scripts fit TOML multi-line strings without escaping.
  • The engine (kiln-core) is UI-agnostic — no rendering/UI types leak in (e.g. log colors are core Rgba8, not a front-end type), so the same game data drives multiple front-ends. kiln-tui being added with zero engine changes is the proof.

Code style

  • Add /// rustdoc comments to every pub type, field, and function. The user reads rustdoc in their IDE to understand the code while making changes.
  • Add inline // comments inside non-trivial function bodies to explain the why of each logical step — especially in update loops, rendering math, and conversion logic.
  • Keep comments accurate: update them when the code they describe changes.

Finishing an epic

When the user says "finish the epic", do all of the following in order:

  1. Update CLAUDE.md to reflect any new modules, types, or behaviors added during the session.
  2. Run the /simplify skill on changed code.
  3. Commit everything with a summary message.

Commands

cargo build                                # compile the workspace (kiln-core + kiln-tui)
cargo run -p kiln-tui -- maps/start.toml   # play a board in the terminal
cargo test                                 # run all tests
cargo test <name>                          # run a single test by name (substring match)
cargo clippy                               # lint
cargo fmt                                  # format

Architecture

kiln is a Cargo workspace that separates the engine from its front-ends so the same game data can be driven by different UIs:

  • kiln-core — the engine: all core game types (Board, Glyph, Archetype, GameState, …) plus .toml map-file load/save. No rendering or UI; every front-end depends on it.
  • kiln-tui — a terminal player (no editor yet) built on ratatui 0.30 / crossterm. Takes a map-file path on the command line and lets you walk the player around the board with the arrow keys.
  • kiln-egui — the original eframe/egui desktop app (player + editor). Still in the tree but no longer a workspace member: it was removed from the root Cargo.toml members and its files were left untouched, so it is not built at the root and is not guaranteed to compile against the current engine. Its notes below are retained for when it is revived.

Root Cargo.toml: members = ["kiln-core", "kiln-tui"].

kiln-core modules

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

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

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

kiln-core/src/font.rs — font override (pub(crate)):

  • FontSpec { path: String, tile_w: u32, tile_h: u32 } — optional per-board bitmap font. When None, the app default is used.

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

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

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

  • Board — the complete game unit (ZZT-style "board"): width, height, cells: Vec<(Glyph, Archetype)> (row-major; pub(crate)), floor: Vec<Glyph> + floor_spec: Option<FloorSpec> (visual floor layer; pub(crate)), player: Player, objects: BTreeMap<ObjectId, ObjectDef>, next_object_id: ObjectId, portals: Vec<PortalDef>, font: Option<FontSpec>, zoom: u32 (integer tile scale factor), scripts: HashMap<String, String> (named Rhai source text; script name → source), board_script_name: Option<String> (name of a board-level script, if any), load_errors: Vec<LogLine> (pub(crate)).
  • get(x, y) -> &(Glyph, Archetype) / get_mut — direct cell access (panics OOB).
  • 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 playerglyph_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.
  • add_object(obj) -> ObjectId, object_ids_at(x, y), solid_object_id_at(x, y) — object queries.
  • in_bounds((i32, i32)) — bounds-checks a possibly-negative coord.
  • is_valid() / load_errors() / report_error(msg) — nonfatal load-error surface.

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

  • SpeechBubble { object_id, text, remaining } — an active speech bubble created by a script's say(s) call. remaining counts down in GameState::tick; when it reaches zero the bubble is removed. At most one bubble per object (a new say() replaces the old one).
  • SAY_DURATION: f64 — how long a bubble lives (3.0 seconds).
  • 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 (Loglog, SetTile → source glyph, Move(dir)step_object, Sayspeech_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 fixedFLOOR_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 tileSingle; a table with contentGrid. 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).

kiln-core/src/log.rs — styled log messages:

  • LogSpan { text, fg: Option<Rgba8>, bg: Option<Rgba8> } and LogLine { spans: Vec<LogSpan> } — a UI-agnostic styled message (colors are core Rgba8, not a front-end type). LogLine::raw(), a chainable push(), append(), and LogLine::error() (a red-on-black single-span constructor used for nonfatal load errors) build messages; each front-end converts a LogLine to its own styled text at render time.

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

  • ScriptHost — owns the Rhai Engine, the compiled scripts referenced by a board's objects (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 tagrun calls call_fn_with_options(...with_tag(object_id)...) and the host fns read it via NativeCallContext::tag() (decoded back to an ObjectId by source_of). Scripts write move(North) without naming themselves; North/South/East/West are Direction constants in scope, and impl From<Direction> for (i32,i32) gives the delta.
  • GameState (hence the Engine/Scope) is single-threaded / not Send; fine for kiln-tui.
  • Sender identity uses stable ids: BoardAction.source, ObjectRuntime.object_id, and the QueueMap keys are all the object's ObjectId (matching Board::objects' BTreeMap keys), so they survive object spawn/destroy/reorder. The per-call tag carries the id as an i64 (id 0 — never valid — is the no-source fallback). ScriptHost::objects is still a Vec<ObjectRuntime> built by iterating the board map, so it stays in ascending-id order.

kiln-core/src/map_file.rs — map file loading:

  • MapFile and friends — serde Deserialize types for TOML map files
  • TileIndex#[serde(untagged)] enum accepting either Num(u32) or Chr(char); lets map files use tile = " " (char) or tile = 32 (integer) interchangeably
  • 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
  • FontHeader — deserializes the optional [font] section (path, tile_w, tile_h)
  • 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::polls only until the next frame deadline (so input stays responsive) and otherwise wakes to advance the game by the real elapsed dt via GameState::tick. It acts on Press and Repeat key events (so holding a key moves continuously) and ignores Release (which the Kitty protocol also emits, and which would otherwise double-fire each move). Arrow keys move the player via GameState::try_move; l toggles the log panel; q/Esc quit. Mouse capture is enabled so the log panel scrolls (wheel) and resizes (drag the divider). game.run_init() runs object init() hooks after the board is loaded and before the loop.
  • draw wraps the board in a bordered Block titled with the board name and controls. When the log panel is closed, the latest log line is previewed in the bottom-left border after a [l]og: label; when open, a bottom panel lists the log newest-first (render::logline_to_line converts each LogLine).
  • Ui { log_open, log_height, scroll } — view-only panel state kept in the front-end (not on GameState).

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

  • CP437: [char; 256] — full code-page-437 table (graphic glyphs for 0x000x1F, box-drawing for 0xB00xDF, etc.).
  • tile_to_char(tile: u32) -> char — indexes the table for tile < 256; falls back to char::from_u32 then a blank space. Unit-tested.

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

  • rgba8_to_color(Rgba8) -> Color::Rgb — lossless RGB bridge (alpha dropped); truecolor terminals show exact colors, 256-color ones approximate.
  • BoardWidget<'a> — a ratatui::widgets::Widget that draws the board. Per axis, axis(board_len, view, player) -> (offset, pad, count) (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 SpeechBubbles. 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
  • BitmapFont::tile_cols(), tile_count(), img_w(), img_h() — geometry helpers used by the glyph picker and font dialog

kiln-egui/src/font_dialog.rs — font picker dialog:

  • FontDialogState — in-progress edit: path: String, tile_w/tile_h: u32, preview: Option<BitmapFont>
  • FontDialogState::from_spec(spec) — initializes from an existing FontSpec or defaults (8×16, empty path)
  • show(ctx, open, state, font_spec) — floating dialog with file browser (rfd::FileDialog), tile dimension DragValue controls, scrollable tilesheet preview with grid overlay, and Apply / Use default buttons. Uses a should_close flag to avoid the egui .open() borrow conflict.

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

  • AppMode enum (Play | Edit) — gates arrow-key input; toggles the side panel and viewport mode
  • App holds GameState, AppMode, EditorState, default_font: BitmapFont, and board_font: Option<BitmapFont>
  • App::new(board, egui_ctx) — loads assets/vga-font-8x16.png as the default font (falls back to create_placeholder); loads per-board font from board.font if present
  • App::update is a thin sequence of phase methods: handle_input (table-driven arrow keys, Play only) → menu_bar (File Save/Save As via save_to/save_as, Exit, mode toggle) → try_show_script_editor (returns true to take over the frame) → editor side panel + font reload → show_boardhandle_board_clickshow_glyph_pickers → object-glyph writeback
  • App::active_font() — resolves the per-board font or the default; used wherever an exclusive borrow of self is not also needed
  • App::show_board(&self) -> Option<(usize, usize)> — draws the viewport and returns the clicked cell (Edit mode); the click is dispatched to handle_board_click(&mut self, cx, cy) after show_board returns, so the &mut handler never conflicts with the font borrow
  • Font change detection: font_spec_before is snapshotted before show_editor_panel; if changed, apply_font_spec reloads board_font
  • Borrow split: at the editor-panel and glyph-picker call sites, self.board_font.as_ref().unwrap_or(&self.default_font) is used inline (not active_font()) so the font borrow stays disjoint from the &mut self.editor/&mut board borrows
  • Play mode: arrow keys move player; render::board_origin centers or player-tracks the viewport
  • Edit mode: ScrollArea::both() wraps the board; click-to-paint stamps (editor.palette.glyph, editor.palette.selected); calls editor::show_editor_panel and glyph_picker::show

kiln-egui/src/render.rs — cell rendering and drawing primitives:

  • Window sizing constants (DEFAULT_WINDOW_W = 840, DEFAULT_WINDOW_H = 524, MIN_WINDOW_W/H); no fixed CELL_W/H — cell size comes from the active BitmapFont
  • rgba8_to_color32(c) / color32_to_rgba8(c) — inverse bridges between core color::Rgba8 and egui Color32
  • paint_glyph(painter, rect, glyph, font)rect_filled with glyph.bg, then painter.image tinted by glyph.fg
  • glyph_preview_button(ui, glyph, font) -> Response — allocates a one-cell swatch (unzoomed), paints glyph, returns the click response; used by the Palette and Objects tabs
  • draw_glyph(painter, origin, x, y, glyph, font, zoom) — sizes cell via font.cell_size(zoom), delegates to paint_glyph
  • draw_board(painter, origin, board, font, zoom) — draws all cells then player overlay
  • draw_object_overlays(painter, origin, board, font, selected, zoom) — highlights all object cells in the Objects editor tab; the selected object gets a brighter border
  • board_origin(available, board_w, board_h, player, font, zoom) — centers board or clamps to player with no empty space
  • pos_to_cell(origin, pos, tile_w, tile_h) -> (i32, i32) — pixel → cell coordinates; negatives signal out-of-bounds

kiln-egui/src/editor.rs — editor state and side panel:

  • EditorTab enum (Palette | Objects | Board | Scripts | World) — which tab is active
  • EditorStatetab: EditorTab plus four concern-grouped sub-structs so each tab takes only the borrow it needs:
    • palette: PaletteStateselected: Archetype, glyph: Glyph, picker_open: bool (selecting a new archetype resets glyph to its default)
    • font_dialog: FontDialogopen: bool, state: FontDialogState
    • objects: ObjectEditselected: Option<usize>, picker_open: bool, editing_glyph: Glyph, placing: bool
    • scripts: ScriptEditediting: Option<String>, content: String, new_name: String
  • show_editor_panel(ctx, editor, board, active_font) — resizable right-side panel (default 200 px); renders the tab bar then dispatches to a per-tab function (palette_tab, objects_tab, board_tab, scripts_tab; World is empty), passing the relevant sub-struct(s). Palette shows the archetype list and glyph preview button; Board shows the font path, "Font…" button, and zoom slider; Objects lists the selected object's glyph preview, Passable/Opaque checkboxes, and script combobox; Scripts creates/edits named scripts

kiln-egui/src/glyph_picker.rs — floating glyph picker dialog:

  • show(ctx, open, glyph, board_cells, font) — takes open: &mut bool and glyph: &mut Glyph directly; no dependency on EditorState
  • Three sections: board palette (unique glyphs deduped via HashSet<Glyph>, click to select), FG/BG color pickers, scrollable tile grid (all tiles in the active font at tile_w × tile_h per cell)

Map file format (maps/*.toml)

XPM-inspired: a [palette] maps single characters to (Glyph, Archetype) definitions; [grid] content is a TOML multi-line string where each character indexes the palette.

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

[map]
name = "Room Name"
width = 60
height = 25
player_start = [30, 12]   # OR a grid char: player_start = "@"

# 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" }
"o" = { archetype = "crate", tile = 254, fg = "#aaaaaa", bg = "#000000" }  # solid + pushable (any dir)
"-" = { archetype = "hcrate", tile = 29, fg = "#aaaaaa", bg = "#000000" }  # crate, pushable east/west only
"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" }  # crate, pushable north/south only

# Optional visual floor layer (drawn under every empty cell). Four forms:
#   floor = "grass"                              # 1. one generator for the whole board
#   [floor]                                      # 2. one fixed glyph everywhere
#   tile = "."   fg = "#1c3a1c"  bg = "#0a1a0a"
#   [floor]                                      # 3. a grid with its own palette
#   content = """..."""                          #    (must match width/height)
#   [floor.palette]                              #    entries are generator names OR glyphs
#   "g" = "grass"                                #    generator: "grass" | "dirt" | "stone"
#   "." = { tile = ".", fg = "#222", bg = "#000" }
# (Omitting [floor] entirely = black-on-black space, the default.)
[floor]
content = """
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
gggggggggggggggddddddddddddddddddddddddddddddggggggggggggggg
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
"""
[floor.palette]
"g" = "grass"
"d" = "dirt"

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

[[objects]]          # optional
# Placement: either `x`/`y`, OR a `palette` char that appears in the grid.
# Convention: object palette chars are uppercase letters (here `G`). The char
# must appear exactly once in the grid, be unique across objects, and not be a
# [palette] key; the cell under it loads as Empty.
palette = "G"
tile = "#"
fg = "#aa3333"
bg = "#000000"
solid = false             # blocks movement? defaults true. (pushable defaults false)
name = "greeter"          # optional unique name; readable via my_name() / object_id_for_name()
script_name = "greeter"   # references a key in [scripts]

[[portals]]          # optional; parsed but not yet runtime-wired
x = 59
y = 12
target_map = "cave"
target_entry = "west_door"

# Named Rhai scripts, referenced by objects via `script_name`.
[scripts]
greeter = """
fn init() {                          # optional, run once at startup
    log(`player at ${Board.player_x}, ${Board.player_y}`);   # read the world
    set_tile(2);                     # write: change my glyph
}
# optional, run every frame; dt = elapsed seconds. move() costs 250 ms, so the
# object steps ~4 cells/sec regardless of frame rate. blocked(dir) avoids walking
# into a solid (or another object's already-queued move); Queue.length()/Queue.clear()
# inspect/empty this object's own pending-action queue.
fn tick(dt) {
    if Queue.length() == 0 && !blocked(North) { move(North); }
}
# optional, fires when something steps into this object; id = bumper's object id,
# or -1 for the player.
fn bump(id) { log(`bumped by ${id}`); say("Ouch!"); }
"""

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) (dirNorth/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).

Key design decisions

  • Behavior and Archetype are separate typesArchetype is the named class of a thing (Wall, Empty, Object); Behavior is its runtime properties (solid, opaque, pushable). Adding a new property means adding a field to Behavior, not a match arm at every call site.
  • The floor layer is how Empty is drawnBoard::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 matches in behavior()/name()/default_glyph() are exhaustive (the compiler flags them), but TryFrom<&str> and ALL_ARCHETYPES are not — forget the TryFrom arm and the archetype silently loads as ErrorBlock. Also update the map-format example.
  • Board is the complete unit — grid, player, objects, and portals all live on Board, matching how ZZT treats a "board". No separate wrapper struct.
  • Glyph (visual) and Archetype (behavior) are decoupled — each cell has its own Glyph (so colors can vary per-cell, e.g. fire flickering) while sharing an Archetype with other cells of the same type.
  • File loading happens in main() before the window is created, so board dimensions are available for window sizing.

eframe runs on its own event loop thread; do not assume single-threaded execution.

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.

Other not-yet-implemented threads

  • Scripting growth — more event hooks beyond init/tick/bump (e.g. on_touch when the player steps onto an object), a larger action/read vocabulary (more actions could carry a time_cost), and persisting script-local state across ticks (needs call_fn_raw so the per-object Scope isn't rewound each call).
  • Runtime spawn/destroy of objects — objects now have stable ObjectIds (Board::objects is a BTreeMap<ObjectId, ObjectDef> with a next_object_id counter; add_object allocates), so references survive reordering. What's still missing: a remove_object, and wiring live spawn/destroy into the ScriptHost (it builds one ObjectRuntime per object once in GameState::new, so an object added after that has no script runtime until the host is rebuilt).
  • Portals & multi-boardPortalDef is parsed but not runtime-wired; there is no multi-board world/loading yet (the engine loads a single map).
  • Load-error surfacingBoard::load_errors() is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup).