47 KiB
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 coreRgba8, 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 everypubtype, 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 inupdateloops, 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:
- Update
CLAUDE.mdto reflect any new modules, types, or behaviors added during the session. - Run the
/simplifyskill on changed code. - 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.tomlmap-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 rootCargo.tomlmembersand 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 aconst fn(tile 64@, white on dark blue); all other glyphs come from map files.Hashis hand-implemented via packedu32representations so it stays in sync withEq.
kiln-core/src/archetype.rs — element taxonomy:
Archetype(Copy,PartialEq,Hash) — enum of named element types:Empty,Wall,Crate,HCrate,VCrate,ErrorBlock. Each variant providesbehavior(),name()(used in map files), anddefault_glyph().Cratepushable any direction (CP437 ■, char 254);HCrate/VCratepushable east/west (↔, char 29) / north/south (↕, char 18) only.ErrorBlockis a sentinel for unknown archetype names (yellow?on red).TryFrom<&str>parses by name; unrecognized names returnErr(the map loader substitutesErrorBlock).
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 fromArchetype::behavior():solid: bool,opaque: bool,pushable: Pushable.ObjectId = u32— stable identifier for board objects.Solid— the single solid occupant of a cell, returned byBoard::solid_at:Player,Cell(Archetype), orObject(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. WhenNone, 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(defaulttrue),opaque: bool(defaulttrue),pushable: bool(defaultfalse),script_name: Option<String>,tags: HashSet<String>,name: Option<String>. The optionalnameis validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared toNone(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),board_script_name: Option<String>(name of a board-level script in the world script pool, if any),load_errors: Vec<LogLine>(pub(crate)). Scripts are not stored onBoard— they live in [World::scripts].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 player —glyph_atat the player's cell returnsGlyph::player(). AnEmptycell'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 ofsolid_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, leavingEmptybehind.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'ssay(s)call.remainingcounts down inGameState::tick; when it reaches zero the bubble is removed. At most one bubble per object (a newsay()replaces the old one).SAY_DURATION: f64— how long a bubble lives (3.0 seconds).Scroll { source: ObjectId, lines: Vec<ScrollLine> }— an active scroll overlay opened by a script'sscroll(lines)call. Lives onGameState::active_scroll: Option<Scroll>; front-ends pause ticks while it'sSomeand close it viaclose_scroll(choice).ScrollLineis re-exported fromaction.rsthroughgame.rsso front-ends import both fromkiln_core::game.GameState— ownsworld: World(all boards asRc<RefCell<Board>>+ world scripts) andcurrent_board_name: String(key of the active board).pub log: Vec<LogLine>,scripts: ScriptHost,pub speech_bubbles: Vec<SpeechBubble>,pub active_scroll: Option<Scroll>. Front-ends reach the active board throughboard() -> Ref<Board>/board_mut() -> RefMut<Board>(both look upworld.boards[current_board_name]).current_board_name() -> &strreturns the active key.from_world(world: World) -> Selfis the primary constructor: clones the start board'sRc<RefCell<Board>>for theScriptHost, compiles scripts fromworld.scripts.new(board)andwith_scripts(board, scripts)are#[cfg(test)]-only sugar that build a minimal single-boardWorld.try_move(dir)moves the player (pushing if possible) and firesbump(-1)on any solid object walked into.run_init()runs objectinit()hooks once at startup.tick(dt)advances cooldowns, expires speech bubbles, and runstick(dt)hooks every frame. Both end by callingresolve(), which drains the board queue and applies eachAction(Log→log,SetTile→ source glyph,Move(dir)→step_object,Say→speech_bubbles,Scroll→active_scroll). Resolution is two-phase: mutate the board collecting(bumped, bumper)pairs, drop the borrow, then firerun_bumpfor each pair.drain_errors()moves script errors intolog.close_scroll(choice)clearsactive_scrolland optionally firesrun_send(source, choice, None)to dispatch the player's selection back to the object.
kiln-core/src/action.rs — the Action enum and its Rhai-facing conversion:
MOVE_COST: f64— how long a move occupies an object before it can act again (0.25 s).ScrollLine(pub) — one line of content in aScrollaction:Text(String)(plain, word-wrapped) orChoice { choice, display }(selectable;choiceis sent back to the source object when the player picks it).Action(pub(crate)) — deferred mutation emitted by a script and applied byGameStateafter promotion onto the board queue:Move(Direction),SetTile(u32),Log(LogLine),SetTag { target, tag, present },Say(String),Delay(f64)(never reaches the board queue — consumed byScriptHost::drainto pace the object),SetColor { fg, bg },Send { target, fn_name, arg },Scroll(Vec<ScrollLine>).action_to_map(action) -> rhai::Map— converts anActionto a Rhai map forQueue.peek()/Queue.pop(). The map always has a"type"key; other keys carry the payload.Scrollemitstypeonly (lines are not inspectable via the map API).Logis flattened to its first span's text.
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 howEmptyis drawn; the cell's ownEmptyglyph is ignored). Computed once at load and cached onBoard::floor, so there is no per-frame cost / flicker; the rawFloorSpecis also kept (Board::floor_spec) somap_file::saveround-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. ,). Usestinyrand(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), orGrid { content, palette: HashMap<String, FloorTile> }(a grid with its own palette). Untagged ordering matters: a string →Generator; a table withtile→Single; a table withcontent→Grid.FloorTile(untagged) is a grid-palette entry: a generator name (string) or aFloorGlyph(table).FloorGlyph { tile: TileIndex, fg, bg }reusesmap_file::TileIndex(nowpub) andparse_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> }andLogLine { spans: Vec<LogSpan> }— a UI-agnostic styled message (colors are coreRgba8, not a front-end type).LogLine::raw(), a chainablepush(),append(), andLogLine::error()(a red-on-black single-span constructor used for nonfatal load errors) build messages; each front-end converts aLogLineto its own styled text at render time.
kiln-core/src/script.rs — Rhai scripting runtime:
ScriptHost— owns the RhaiEngine, the compiled scripts referenced by a board's objects (compiled once per name), a per-object persistentScope+ output queue + ready timer, and the shared board queue. Built withScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>): the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool (scripts are compiled from here, not from the board itself). Reports compile/unknown-script failures onto the error sink; runs nothing.- Lifecycle hooks per object:
init()(zero-arg),tick(dt)(elapsed seconds asf64), andbump(id)(the bumper'sObjectId, or-1for the player), all optional — detected viaAST::iter_functions()by name and arity. Driven byrun_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 onRc<RefCell<Board>>(theBoardRefalias, exposed to Rhai under the type nameBoard) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constantBoard.blocked(dir) -> boolis 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) -> boolandget_tags() -> Arrayread the calling object's tag set;objects_with_tag(s) -> Arrayreturns all object ids carrying that tag.my_name() -> Stringreturns the calling object's name (or""if unnamed);object_id_for_name(s) -> i64looks up an object by name and returns its id, or0if not found. Each getter briefly borrows the sharedBoard. - Actions & rate limiting (two-tier queues): host fns
move(dir),set_tile(n),log(s),say(s),scroll(lines)append anAction(Move/SetTile/Log/Say/Scroll;MOVE_COST = 0.25s forMove, else 0) to the issuing object's output queue (routed by the per-call tag via aHashMap<usize, ObjQueue>).scroll(lines)takes a Rhai array where each element is a string (text line) or a two-element array[choice_key, display_text](selectable choice).ScriptHost::pump(i)promotes ready actions onto the shared board queue (Vec<BoardAction { source, action }>): the leading run of zero-cost actions plus at most one timed action, which arms that object's ready timer and ends the pump. While a ready timer is> 0nothing is pulled (this caps object speed);advance_timers(dt)counts them down each frame.GameStatedrains the board queue withtake_board_queue()and applies it after the batch — so scripts mutate without a&mut GameStateborrow. Errors (compile/runtime) bypass the queues via the error sink (take_errors()). - The
Queueobject (a handle to the calling object's output queue) is pushed into each scope; scripts callQueue.length(),Queue.clear(),Queue.peek()(front action as a Rhai map, or()if empty), andQueue.pop()(same, removes the front). Safe to mutate from script because the host only touches output queues between calls (duringpump). - Sender identity:
source(which object issued an action) rides the per-call tag —runcallscall_fn_with_options(...with_tag(object_id)...)and the host fns read it viaNativeCallContext::tag()(decoded back to anObjectIdbysource_of). Scripts writemove(North)without naming themselves;North/South/East/WestareDirectionconstants in scope, andimpl From<Direction> for (i32,i32)gives the delta. GameState(hence theEngine/Scope) is single-threaded / notSend; fine for kiln-tui.- Sender identity uses stable ids:
BoardAction.source,ObjectRuntime.object_id, and theQueueMapkeys are all the object'sObjectId(matchingBoard::objects'BTreeMapkeys), so they survive object spawn/destroy/reorder. The per-call tag carries the id as ani64(id 0 — never valid — is the no-source fallback).ScriptHost::objectsis still aVec<ObjectRuntime>built by iterating the board map, so it stays in ascending-id order.
kiln-core/src/map_file.rs — per-board serde types and single-board load/save:
MapFileand friends — serdeDeserialize/Serializetypes for the per-board portion of a.tomlfile. Does not include scripts (those live inWorld::scripts, not onBoard).TileIndex—#[serde(untagged)]enum accepting eitherNum(u32)orChr(char); lets map files usetile = " "(char) ortile = 32(integer) interchangeablyPlayerStart—#[serde(untagged)]enum (Coord([i32;2])|Char(char));player_start = [x, y]orplayer_start = "@"(locate that char in the grid). Same dual-form trick asTileIndexFontHeader— 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 returnsErr; every other problem (unknown archetype/grid char →ErrorBlock; object/player placement-char issues; one-solid-per-cell conflicts) is recovered and recorded onBoard::load_errors(seeBoard::is_valid). Resolves the player (coord or@char, first-occurrence, falling back to(0,0)) and lets it win its cell; places objects byx/yorpalettecharpub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>>— reads a single-board.tomlfile (legacy format). Production code usesworld::loadinstead.pub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>>— serializesBoardback to a single-board TOML file
kiln-core/src/world.rs — world type and world-file loading:
World— the runtime representation of a.tomlworld file:name: String,start: String(key of the starting board),scripts: HashMap<String, String>(named Rhai source, shared across all boards),boards: HashMap<String, Rc<RefCell<Board>>>(all boards, always present). Every board is wrapped inRc<RefCell<Board>>so multiple shared refs can coexist —GameStateclones the active board'sRcfor itsScriptHost, and all boards remain inworld.boardsat all times (no board is ever "removed" when active).pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>>— reads a world.tomlfile, converts each[boards.NAME.*]subtable viaTryFrom<MapFile> for Board, wraps each inRc::new(RefCell::new(...)), and validates thatworld.startmatches a board key.
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.
Drawing rule: prefer ratatui primitives. Before writing manual cell-by-cell buffer code, check whether a Block, Paragraph, Layout, Line::centered(), or other ratatui widget/method achieves the same result. If a small design change (e.g. fixed vs. content-driven sizing) would unlock a ratatui-native approach, raise it as a question rather than writing manual code. The documented exceptions are: the background dimming pass in ScrollOverlayWidget (no ratatui primitive for "tint the whole buffer"), and the tail characters in SpeechBubblesWidget (speech.rs): the tail-join ┬ on the bottom border and the │ column drawn below the box down to the source object.
kiln-tui/src/main.rs — entry point and play loop:
- Parses a single positional arg (the world file path); prints usage and exits non-zero if missing. Loads the world via
kiln_core::world::loadbefore touching the terminal so load errors print to a normal screen, then callsGameState::from_world(world)to start on the world's designated start board. ratatui::init()→ detectTerminalCaps→ push Kitty flags when supported →runloop → pop Kitty flags →ratatui::restore().runis a ~30 FPS real-time loop: itevent::polls only until the next frame deadline (so input stays responsive) and otherwise wakes to advance the game by the real elapseddtviaGameState::tick. It acts onPressandRepeatkey events (so holding a key moves continuously) and ignoresRelease(which the Kitty protocol also emits, and which would otherwise double-fire each move). Arrow keys move the player viaGameState::try_move;ltoggles the log panel;q/Escquit. Mouse capture is enabled so the log panel scrolls (wheel) and resizes (drag the divider).game.run_init()runs objectinit()hooks after the board is loaded and before the loop. Whengame.active_scrollisSome, all input is redirected to scroll navigation (Up/Down to scroll, letter keys for choices, Esc to dismiss if no choices);game.tick()is skipped until the overlay is closed.drawwraps the board in a borderedBlocktitled 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_lineconverts eachLogLine). The scroll overlay is drawn last (above everything) byrender::draw_scroll_overlay.Ui { log: LogState, overlay: ScrollOverlayState }— view-only panel state kept in the front-end (not onGameState).Ui::tick(dt, game)advances animations, dispatches finished close events, and ticks the game when no overlay is blocking.begin_close(choice)starts the close animation.
kiln-tui/src/cp437.rs — CP437 → Unicode mapping:
CP437: [char; 256]— full code-page-437 table (graphic glyphs for 0x00–0x1F, box-drawing for 0xB0–0xDF, etc.).tile_to_char(tile: u32) -> char— indexes the table fortile < 256; falls back tochar::from_u32then a blank space. Unit-tested.
kiln-tui/src/render.rs — board → ratatui buffer:
BoardWidget<'a>— aratatui::widgets::Widgetthat 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 callsBoard::glyph_at, which already folds in the player glyph at the player's position — no separate player-overlay pass needed.
kiln-tui/src/speech.rs — speech bubble widget:
SpeechBubblesWidget<'a>— aratatui::widgets::Widgetthat draws speech bubbles for all activeSpeechBubbles over the board. Two-pass: first collects all placements (sorted bottom-first so upward-shift decisions don't interfere with higher bubbles), then renders in reverse order (top-most first) so lower bubbles' boxes naturally cover extended tails without an explicit occlusion check.- Each bubble:
Block::bordered()+Paragraphfor the box; the tail-join┬on the bottom border and a column of│chars from below the box down to the source object are written manually. Bubbles that would overlap are shifted upward one row at a time (up to 20 attempts) until clear. place_bubble(area, obj_sx, obj_sy, lines, placed) -> Option<Rect>— sizes and places one bubble, appending its rect toplacedon success.board_screen_pos(area, board, bx, by) -> Option<(u16, u16)>— converts a board cell coordinate to terminal screen coordinates, returningNoneif off-screen. Used by bubble placement.
kiln-tui/src/scroll_overlay.rs — scroll overlay widget:
ScrollAnimState { Opening(f32), Open, Closing(f32), Done }— open/close animation progress (0.0–1.0).Donesignals the caller to dispatch the pending choice and reset state.ScrollOverlayState { offset, max_scroll, anim, pending_choice }— threaded through render asStatefulWidgetstate.advance_anim(dt)ticks the animation;close_finished()/take_choice()let the caller dispatch whenDone.max_scrollis an output written during render.ScrollOverlayWidget<'a>—StatefulWidgetthat dims the background, renders a bordered window at ½ width × ¾ height with animated height (forced even), wraps text, centers whitespace-leading lines, prefixes choices with[a]/[b], and renders a scrollbar when content overflows.
kiln-tui/src/ui.rs — top-level front-end state:
Ui { log: LogState, overlay: ScrollOverlayState }— aggregates all presentation state.tick(dt, game)advances animations, dispatches finished close events, ticks the game when no overlay blocks, and starts the open animation when a new scroll appears.scroll_lines(delta)adjustsoverlay.offset.begin_close(choice)starts the closing animation (uses current opening progress if still animating in).
kiln-tui/src/input.rs — input dispatch:
InputMode { Board(bool), Scroll }— which input mode is active;Board(log_open)for normal play,Scrollwhen the overlay is blocking.current_input_mode(game, ui) -> InputMode— derives mode from game + animation state.handle_board_input(event, log_open, terminal_h, game, ui) -> bool— processes arrow keys (move player),l(toggle log),q/Esc(quit), PageUp/Down (scroll log), mouse drag (resize log). Returnstrueif the player quit.handle_scroll_input(event, game, ui)— processes Up/Down (scroll), letter keys (select choice viaresolve_choice), Esc (dismiss if no choices), mouse wheel.
kiln-tui/src/log.rs — log panel widget:
LogState { open, height, scroll }— panel visibility, height in rows, and scroll offset.toggle(),scroll_by(delta, log_len),resize(target, total).LogWidget<'a>—StatefulWidgetthat renders all log lines newest-first in a bordered block with word-wrap.logline_to_line(line) -> Line— converts a coreLogLineinto a styled ratatuiLine; eachLogSpan's optional fg/bg is applied only when present.log_preview_line(logs) -> Line— builds the[l]og: <latest>preview span for the board's bottom border.
kiln-tui/src/utils.rs — shared rendering helpers:
rgba8_to_color(Rgba8) -> Color— lossless RGB bridge (alpha dropped).rects_overlap(a, b) -> bool— true if twoRects share at least one cell; used by bubble placement.
kiln-tui/src/term.rs — terminal capability detection and Kitty setup:
TerminalCaps { keyboard_enhancement: bool, truecolor: bool }— detected once at startup.keyboard_enhancementis a real query/response (supports_keyboard_enhancement());truecolorreads$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; whentruecolor, 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. EnablingREPORT_EVENT_TYPESis why the input loop must filter outReleaseevents.
kiln-egui modules (no longer a workspace member)
kiln-egui/src/font.rs — bitmap font loading and UV mapping:
BitmapFont— wraps an eguiTextureHandle(preprocessed to opaque-white / transparent) plus tile dimensions. The top-left pixel of the source PNG defines the "background" color; those pixels becomeTRANSPARENT, all others becomeWHITE. At render time,painter.image(…, tile_uv, fg_color)tints white pixels tofgand transparent pixels reveal thebgfill behind.BitmapFont::load(ctx, path, tile_w, tile_h)— loads from diskBitmapFont::from_bytes(ctx, bytes, tile_w, tile_h, label)— loads from embedded bytesBitmapFont::create_placeholder(ctx)— 16×16 grid of 8×8 procedural tiles (bit-pattern bars), used as fallback when no font file is presentBitmapFont::tile_uv(tile)— returns a UVRectfor the given tile index (left-to-right, top-to-bottom). Unit-tested viacompute_tile_uv.BitmapFont::cell_size(zoom)— pixel size (Vec2) of one rendered cell at the given integer zoom; centralizes thetile_w * zoommath used across rendering and hit-testingBitmapFont::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 existingFontSpecor defaults (8×16, empty path)show(ctx, open, state, font_spec)— floating dialog with file browser (rfd::FileDialog), tile dimensionDragValuecontrols, scrollable tilesheet preview with grid overlay, and Apply / Use default buttons. Uses ashould_closeflag to avoid the egui.open()borrow conflict.
kiln-egui/src/main.rs — app entry point and frame loop:
AppModeenum (Play|Edit) — gates arrow-key input; toggles the side panel and viewport modeAppholdsGameState,AppMode,EditorState,default_font: BitmapFont, andboard_font: Option<BitmapFont>App::new(board, egui_ctx)— loadsassets/vga-font-8x16.pngas the default font (falls back tocreate_placeholder); loads per-board font fromboard.fontif presentApp::updateis a thin sequence of phase methods:handle_input(table-driven arrow keys, Play only) →menu_bar(File Save/Save As viasave_to/save_as, Exit, mode toggle) →try_show_script_editor(returnstrueto take over the frame) → editor side panel + font reload →show_board→handle_board_click→show_glyph_pickers→ object-glyph writebackApp::active_font()— resolves the per-board font or the default; used wherever an exclusive borrow ofselfis not also neededApp::show_board(&self) -> Option<(usize, usize)>— draws the viewport and returns the clicked cell (Edit mode); the click is dispatched tohandle_board_click(&mut self, cx, cy)aftershow_boardreturns, so the&muthandler never conflicts with the font borrow- Font change detection:
font_spec_beforeis snapshotted beforeshow_editor_panel; if changed,apply_font_specreloadsboard_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 (notactive_font()) so the font borrow stays disjoint from the&mut self.editor/&mut boardborrows - Play mode: arrow keys move player;
render::board_origincenters or player-tracks the viewport - Edit mode:
ScrollArea::both()wraps the board; click-to-paint stamps(editor.palette.glyph, editor.palette.selected); callseditor::show_editor_panelandglyph_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 fixedCELL_W/H— cell size comes from the activeBitmapFont rgba8_to_color32(c)/color32_to_rgba8(c)— inverse bridges between corecolor::Rgba8and eguiColor32paint_glyph(painter, rect, glyph, font)—rect_filledwithglyph.bg, thenpainter.imagetinted byglyph.fgglyph_preview_button(ui, glyph, font) -> Response— allocates a one-cell swatch (unzoomed), paintsglyph, returns the click response; used by the Palette and Objects tabsdraw_glyph(painter, origin, x, y, glyph, font, zoom)— sizes cell viafont.cell_size(zoom), delegates topaint_glyphdraw_board(painter, origin, board, font, zoom)— draws all cells then player overlaydraw_object_overlays(painter, origin, board, font, selected, zoom)— highlights all object cells in the Objects editor tab; the selected object gets a brighter borderboard_origin(available, board_w, board_h, player, font, zoom)— centers board or clamps to player with no empty spacepos_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:
EditorTabenum (Palette|Objects|Board|Scripts|World) — which tab is activeEditorState—tab: EditorTabplus four concern-grouped sub-structs so each tab takes only the borrow it needs:palette: PaletteState—selected: Archetype,glyph: Glyph,picker_open: bool(selecting a new archetype resetsglyphto its default)font_dialog: FontDialog—open: bool,state: FontDialogStateobjects: ObjectEdit—selected: Option<usize>,picker_open: bool,editing_glyph: Glyph,placing: boolscripts: ScriptEdit—editing: 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)— takesopen: &mut boolandglyph: &mut Glyphdirectly; no dependency onEditorState- 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 attile_w × tile_hper cell)
World file format (maps/*.toml)
Each .toml file is a world: a named collection of boards plus a shared script pool. The [world] header names the world and designates the starting board. Scripts live once at the top level and are referenced by name from any board's objects. Each board lives under [boards.NAME.*] where NAME is the board's identifier string.
Keep this example in sync with world.rs / map_file.rs whenever the format changes.
[world]
name = "My World"
start = "room1" # key of the starting board
# Named Rhai scripts — shared across all boards. Objects reference a script
# by script_name = "key". Scripts do NOT live on individual boards.
[scripts]
greeter = """
fn init() { # optional, run once at startup
log(`player at ${Board.player_x}, ${Board.player_y}`);
set_tile(2); # write: change my glyph
}
fn tick(dt) {
if Queue.length() == 0 && !blocked(North) { move(North); }
}
fn bump(id) { log(`bumped by ${id}`); say("Ouch!"); }
"""
# Each board is a named subtable under [boards]. The board key ("room1") is
# used for cross-board references (e.g. portals) and becomes current_board_name.
[boards.room1.map]
name = "Room One"
width = 60
height = 25
player_start = [30, 12] # OR a grid char: player_start = "@"
# Optional: override the default font for this board.
[boards.room1.font]
path = "assets/my_font.png"
tile_w = 8
tile_h = 16
[boards.room1.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" } # pushable east/west only
"|" = { archetype = "vcrate", tile = 18, fg = "#aaaaaa", bg = "#000000" } # pushable north/south only
# Optional visual floor layer. Four forms:
# boards.room1.floor = "grass" # 1. one generator for the whole board
# [boards.room1.floor] # 2. one fixed glyph everywhere
# tile = "." fg = "#1c3a1c" bg = "#0a1a0a"
# [boards.room1.floor] # 3. a grid with its own palette
# content = """..."""
# [boards.room1.floor.palette]
# "g" = "grass" # generator: "grass" | "dirt" | "stone"
# "." = { tile = ".", fg = "#222", bg = "#000" }
[boards.room1.floor]
content = """
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
gggggggggggggggddddddddddddddddddddddddddddddggggggggggggggg
gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg
"""
[boards.room1.floor.palette]
"g" = "grass"
"d" = "dirt"
[boards.room1.grid]
content = """
############################################################
# G #
############################################################
"""
[[boards.room1.objects]] # optional; array of tables
# Placement: either `x`/`y`, OR a `palette` char that appears in the grid.
# Convention: object palette chars are uppercase letters (here `G`). The char
# may appear multiple times — one ObjectDef is spawned per occurrence in reading
# order. Only one [[objects]] entry may use a given char, and it must not be a
# [palette] key. Each matching cell loads as Empty. If the entry has a `name`,
# only the first spawned instance keeps it (names must be board-unique).
palette = "G"
tile = "#"
fg = "#aa3333"
bg = "#000000"
solid = false # blocks movement? defaults true. (pushable defaults false)
name = "greeter" # optional unique name; readable via Me.name / Board.named()
script_name = "greeter" # references a key in the world-level [scripts] table
[[boards.room1.portals]] # optional; parsed but not yet runtime-wired
x = 59
y = 12
target_map = "room2"
target_entry = "west_door"
The optional [boards.NAME.floor] section declares the visual floor layer (drawn under every empty cell, revealed when a crate is pushed away); see floor.rs. Forms: omitted (black-on-black, the default), a generator name string, a single fixed glyph (table with tile/fg/bg), or a grid with its own palette. The three built-in generators are grass/dirt/stone. Floor parsing is best-effort: unknown generators/chars and dimension mismatches fall back to the default glyph.
Colors are "#RRGGBB" hex strings. player_start accepts [x, y] coordinates or a grid char (convention "@"; that cell loads as Empty). tile accepts a single-character string (tile = " ") or an integer (tile = 35). The grid multi-line string's leading newline is trimmed by TOML. Unknown archetype names produce an ErrorBlock and a logged warning. Objects may be placed by x/y or a single grid palette char (uppercase by convention). Loading is best-effort and nonfatal (only a grid-dimension mismatch is a hard error): each problem is recorded on Board (see is_valid() / load_errors()). The player wins its cell: it silently clears solid terrain under it and drops any conflicting solid object. Script source lives in the world-level [scripts] table; objects reference a script by script_name. Scripts may define optional init(), tick(dt), and bump(id) functions; they read the world through Board.* (e.g. Board.player_x), check blocked(dir) -> bool, inspect/empty their pending actions via Queue.length()/Queue.clear(), and write via host functions move(dir), set_tile(n), log(s), say(s), and scroll(lines). Writes don't take effect immediately: each is queued and at most one move resolves per 250 ms per object. When two objects move into the same cell, lowest id wins and the bumped object receives bump(id).
Script state across board transitions: Rhai Scope local variables reset when the ScriptHost is rebuilt on board entry. Board-side state (object positions, tags, glyph) is preserved because all boards are held as Rc<RefCell<Board>> in World::boards. Scripts that need to persist information across transitions should encode it in board data (e.g. set_tag(Me.id, "visited", true)).
Key design decisions
BehaviorandArchetypeare separate types —Archetypeis the named class of a thing (Wall,Empty,Object);Behavioris its runtime properties (solid,opaque,pushable). Adding a new property means adding a field toBehavior, not a match arm at every call site.- The floor layer is how
Emptyis drawn —Board::glyph_atreturns the per-cellfloorglyph for anyEmptycell, so anEmptycell'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 rawFloorSpecis kept (Board::floor_spec) only so saves round-trip. One consequence: a map that colored anEmptycell's bg no longer shows it — empties always come from the floor layer (default black-on-black). Seefloor.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_atreportsSolid::Playerfor 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 viareport_error); the player wins its cell — its resolved position (including the(0, 0)fallback whenplayer_startis unresolvable) silently clears any solid terrain under it and drops a conflicting object.Board::solid_atrelies 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.ArchetypeisCopyso this is efficient.- Archetypes are referenced by name in map files — so
ALL_ARCHETYPEScan be reordered or extended without breaking saved games. Adding a variant: thematches inbehavior()/name()/default_glyph()are exhaustive (the compiler flags them), butTryFrom<&str>andALL_ARCHETYPESare not — forget theTryFromarm and the archetype silently loads asErrorBlock. Also update the map-format example. Boardis the complete unit — grid, player, objects, and portals all live onBoard, 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 anArchetypewith other cells of the same type. - World loading happens in
main()before the terminal is initialized, so load errors print to a normal screen and board dimensions are known before the game loop starts.
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: Playeris required and non-optional; a player-less board can't be represented (should becomeOption<Player>, or move the player out ofBoardto the engine layer).player_startis effectively required; player spawning should eventually move into the object/script system.GameState::try_movemutatesboard.playerdirectly; 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_touchwhen the player steps onto an object), a larger action/read vocabulary (more actions could carry atime_cost), and persisting script-local state across ticks (needscall_fn_rawso the per-objectScopeisn't rewound each call). - Runtime spawn/destroy of objects — objects now have stable
ObjectIds (Board::objectsis aBTreeMap<ObjectId, ObjectDef>with anext_object_idcounter;add_objectallocates), so references survive reordering. What's still missing: aremove_object, and wiring live spawn/destroy into theScriptHost(it builds oneObjectRuntimeper object once inGameState::new, so an object added after that has no script runtime until the host is rebuilt). - Portals & multi-board —
PortalDefis parsed but not runtime-wired. Multi-board world loading is done:world::loadloads all boards asRc<RefCell<Board>>,GameStateowns the wholeWorldand trackscurrent_board_name. What's still missing: aGameState::enter_board(name)method that updatescurrent_board_nameand rebuilds theScriptHostfor the new board's objects. - Load-error surfacing —
Board::load_errors()is collected but no front-end displays it yet (kiln-tui could append it to the in-game log at startup).