Files
kiln/CLAUDE.md
T

223 lines
22 KiB
Markdown
Raw Normal View History

2026-05-15 23:42:14 -05:00
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
2026-05-17 19:38:41 -05:00
`kiln` is a ZZT-inspired game-making system written in Rust (edition 2024). The goal is a system where players can create games using a scripting language, similar to the classic DOS game ZZT. It uses **Rhai** for scripting (WASM-compatible, sandboxed, pure Rust).
2026-05-15 23:42:14 -05:00
## 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. Update `ARCHITECTURE.md` to reflect the same.
3. Run the `/simplify` skill on changed code.
4. Commit everything with a summary message.
2026-05-15 23:42:14 -05:00
## Commands
```bash
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
2026-05-15 23:42:14 -05:00
```
## 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:
2026-05-15 23:42:14 -05:00
- **`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.
2026-05-15 23:42:14 -05:00
Root `Cargo.toml`: `members = ["kiln-core", "kiln-tui"]`.
2026-05-16 01:51:57 -05:00
### kiln-core modules
**`kiln-core/src/game.rs`** — all core game types:
2026-05-19 00:07:04 -05:00
- `Glyph` (`Copy`, `Eq`, `Hash`) — per-cell visual: `tile: u32` (tilesheet index), `fg/bg: Color32`. `Glyph::player()` is a `const fn`; all other glyphs come from the map file. Derives `Hash` so boards can deduplicate glyphs into a palette.
- `FontSpec` — optional per-board bitmap font override: `path: String`, `tile_w: u32`, `tile_h: u32`. When `None`, the app default font is used.
- `Behavior` — plain data struct of runtime behavioral properties: `passable: bool`, `opaque: bool`. Returned by `Archetype::behavior()`; new properties added here require no match arms elsewhere.
- `Archetype` (`Copy`, `PartialEq`) — enum of named element types: `Empty`, `Wall`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()` (used by the editor when stamping a cell). `ErrorBlock` is a sentinel for unknown archetype names — renders as yellow `?` on red.
- `ALL_ARCHETYPES: &[Archetype]` — ordered list of valid editor choices (excludes `ErrorBlock`).
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `cells: Vec<(Glyph, Archetype)>` (row-major; each cell owns its visual and behavioral class directly), `player: Player`, `objects: Vec<ObjectDef>`, `portals: Vec<PortalDef>`, `font: Option<FontSpec>`. `cells` is `pub(crate)`. `is_passable(x, y)` checks objects before the grid cell: an impassable object blocks even over a passable floor tile.
2026-05-16 01:51:57 -05:00
- `Player``x: i32, y: i32`
2026-06-04 20:11:55 -05:00
- `ObjectDef` — scripted object placed on the board: `x`, `y`, `glyph: Glyph`, `passable: bool`, `opaque: bool`, `script_name: Option<String>`. `passable` defaults `false` and `opaque` defaults `true` in map files. Its `init()`/`tick(dt)` hooks are run by the scripting runtime (see `script.rs`).
- `PortalDef` — parsed from map files, stored on Board; not yet runtime-wired
2026-06-04 23:52:33 -05:00
- `BoardState` — the scriptable world: currently just `board: Board` (room to grow, e.g. a shared variable blackboard). Held by `GameState` behind `Rc<RefCell<BoardState>>` so script host functions can share a handle to it without aliasing the running engine.
- `GameState` — holds `board_state: Rc<RefCell<BoardState>>`, the message `log: Vec<LogLine>`, and a `ScriptHost`. Front-ends/logic reach the board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (there is no `pub board` field). `try_move` moves the player; `run_init()` runs object `init()` hooks once at startup; `tick(dt)` runs object `tick(dt)` hooks every frame. After each script batch, `apply_commands()` drains the script command queue: `Log`/`Error``log`, `SetTile` → the source object's glyph, `Move(dir)``move_object` (bounds + `is_passable`). This deferred apply (writes after the batch) is what keeps script execution borrow-safe.
2026-06-04 20:11:55 -05:00
**`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()`, and `append()` build messages; each front-end converts a `LogLine` to its own styled text at render time.
**`kiln-core/src/script.rs`** — Rhai scripting runtime:
2026-06-04 23:52:33 -05:00
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts referenced by a board's objects (compiled once per name), a per-object persistent `Scope`, and a shared command queue. Built with `ScriptHost::new(&Rc<RefCell<BoardState>>)` (registers the API, compiles scripts, reports compile/unknown-script failures as `GameCommand::Error`; runs nothing).
- Lifecycle hooks per object: `init()` (zero-arg) and `tick(dt)` (elapsed seconds as `f64`), both optional — detected via `AST::iter_functions()` by name and arity. Driven by `run_init()` / `run_tick(dt)`; runtime errors become `Error` commands, not fatal.
- **Reads (direct):** scripts read through `BoardView` — a read-only handle (getters only, so read-only by construction) pushed into each scope as `board`, e.g. `board.player_x`, `board.player_y`, `board.width`, `board.height`. Each getter briefly borrows the shared `BoardState`.
- **Writes (command queue):** host fns `move(dir)`, `set_tile(n)`, `log(s)` push a `Command { source, kind: GameCommand }` (`GameCommand` = `Move(Direction)` / `SetTile(u32)` / `Log(LogLine)` / `Error(String)`) into a shared `Rc<RefCell<Vec<Command>>>`, drained by `GameState::take_commands` and applied after the batch. This is how scripts mutate without a `&mut GameState` borrow.
- **Sender identity:** `source` (which object issued a command) rides the per-call **tag**`run` calls `call_fn_with_options(...with_tag(object_index)...)` and the host fns read it via `NativeCallContext::tag()`. 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.
2026-06-04 20:11:55 -05:00
- `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui.
2026-06-04 23:52:33 -05:00
- **TODO in-code:** `Command.source` (and `ObjectRuntime.object_index`) are array indices into `Board::objects` — a stopgap that breaks under object spawn/destroy/reorder; should become a monotonically-increasing unique object id with objects keyed by it.
2026-05-16 01:51:57 -05:00
**`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
- `FontHeader` — deserializes the optional `[font]` section (`path`, `tile_w`, `tile_h`)
- `impl From<MapFile> for Board` — converts a parsed file into a ready-to-use `Board`; unknown archetype names produce `ErrorBlock`
- `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()`.
2026-06-04 20:11:55 -05:00
- `run` is a ~30 FPS real-time loop: it `event::poll`s only until the next frame deadline (so input stays responsive) and otherwise wakes to advance the game by the real elapsed `dt` via `GameState::tick`. It acts on `Press` **and** `Repeat` key events (so holding a key moves continuously) and ignores `Release` (which the Kitty protocol also emits, and which would otherwise double-fire each move). Arrow keys move the player via `GameState::try_move`; `l` toggles the log panel; `q`/`Esc` quit. Mouse capture is enabled so the log panel scrolls (wheel) and resizes (drag the divider). `game.run_init()` runs object `init()` hooks after the board is loaded and before the loop.
- `draw` wraps the board in a bordered `Block` titled with the board name and controls. When the log panel is closed, the latest log line is previewed in the bottom-left border after a `[l]og:` label; when open, a bottom panel lists the log newest-first (`render::logline_to_line` converts each `LogLine`).
- `Ui { log_open, log_height, scroll }` — view-only panel state kept in the front-end (not on `GameState`).
**`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)`: 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 resolves glyph priority player > object > grid floor, then writes char + fg/bg into the buffer.
**`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:
2026-05-19 00:07:04 -05:00
- `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`.
2026-06-03 17:45:43 -05:00
- `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
2026-05-19 00:07:04 -05:00
- `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:
2026-05-19 00:07:04 -05:00
- `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
2026-05-19 00:07:04 -05:00
- `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
2026-06-03 17:45:43 -05:00
- `App::update` is a thin sequence of phase methods: `handle_input` (table-driven arrow keys, Play only) → `menu_bar` (File Save/Save As via `save_to`/`save_as`, Exit, mode toggle) → `try_show_script_editor` (returns `true` to take over the frame) → editor side panel + font reload → `show_board``handle_board_click``show_glyph_pickers` → object-glyph writeback
- `App::active_font()` — resolves the per-board font or the default; used wherever an exclusive borrow of `self` is not also needed
- `App::show_board(&self) -> Option<(usize, usize)>` — draws the viewport and returns the clicked cell (Edit mode); the click is dispatched to `handle_board_click(&mut self, cx, cy)` *after* `show_board` returns, so the `&mut` handler never conflicts with the font borrow
2026-05-19 00:07:04 -05:00
- Font change detection: `font_spec_before` is snapshotted before `show_editor_panel`; if changed, `apply_font_spec` reloads `board_font`
2026-06-03 17:45:43 -05:00
- 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
2026-06-03 17:45:43 -05:00
- **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:
2026-05-19 00:07:04 -05:00
- 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`
2026-06-03 17:45:43 -05:00
- `rgba8_to_color32(c)` / `color32_to_rgba8(c)` — inverse bridges between core `color::Rgba8` and egui `Color32`
2026-05-19 00:07:04 -05:00
- `paint_glyph(painter, rect, glyph, font)``rect_filled` with `glyph.bg`, then `painter.image` tinted by `glyph.fg`
2026-06-03 17:45:43 -05:00
- `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
2026-05-19 00:07:04 -05:00
- `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:
2026-06-03 17:45:43 -05:00
- `EditorTab` enum (`Palette` | `Objects` | `Board` | `Scripts` | `World`) — which tab is active
- `EditorState``tab: EditorTab` plus 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 resets `glyph` to its default)
- `font_dialog: FontDialog``open: bool`, `state: FontDialogState`
- `objects: ObjectEdit``selected: Option<usize>`, `picker_open: bool`, `editing_glyph: Glyph`, `placing: bool`
- `scripts: 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:
2026-05-19 00:07:04 -05:00
- `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)
2026-05-16 01:51:57 -05:00
### 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.
2026-05-16 01:51:57 -05:00
2026-05-19 00:07:04 -05:00
**Keep this example in sync with `map_file.rs` whenever the format changes.**
2026-05-16 01:51:57 -05:00
```toml
[map]
name = "Room Name"
width = 60
height = 25
player_start = [30, 12]
2026-05-19 00:07:04 -05:00
# Optional: override the default font for this board.
[font]
path = "assets/my_font.png"
tile_w = 8
tile_h = 16
2026-05-16 01:51:57 -05:00
[palette]
2026-05-19 00:07:04 -05:00
" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
"#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }
2026-05-16 01:51:57 -05:00
[grid]
content = """
############################################################
# #
############################################################
"""
2026-06-04 20:11:55 -05:00
[[objects]] # optional
2026-05-16 01:51:57 -05:00
x = 10
y = 5
2026-06-04 20:11:55 -05:00
tile = "#"
fg = "#aa3333"
bg = "#000000"
passable = true
script_name = "greeter" # references a key in [scripts]
2026-05-16 01:51:57 -05:00
[[portals]] # optional; parsed but not yet runtime-wired
x = 59
y = 12
target_map = "cave"
target_entry = "west_door"
2026-06-04 20:11:55 -05:00
# Named Rhai scripts, referenced by objects via `script_name`.
[scripts]
greeter = """
2026-06-04 23:52:33 -05:00
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
}
fn tick(dt) { move(north); } # optional, run every frame; dt = elapsed seconds
2026-06-04 20:11:55 -05:00
"""
2026-05-16 01:51:57 -05:00
```
2026-06-04 23:52:33 -05:00
Colors are `"#RRGGBB"` hex strings. `player_start` is a header field — the player is not a board cell. 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. Script source lives in the `[scripts]` table (name → Rhai source); objects reference a script by `script_name`. Scripts may define optional `init()`/`tick(dt)` functions; they **read** the world through `board.*` (e.g. `board.player_x`) and **write** via host functions `move(dir)` (`dir``north`/`south`/`east`/`west`), `set_tile(n)`, and `log(s)`.
2026-05-16 01:51:57 -05:00
### Key design decisions
- **`Behavior` and `Archetype` are separate types** — `Archetype` is the named class of a thing (`Wall`, `Empty`, `Object`); `Behavior` is its runtime properties (`passable`, `opaque`). Adding a new property means adding a field to `Behavior`, not a match arm at every call site.
- **`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.
2026-05-16 01:51:57 -05:00
- **`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.
2026-05-16 01:51:57 -05:00
- **File loading happens in `main()`** before the window is created, so board dimensions are available for window sizing.
2026-05-15 23:42:14 -05:00
eframe runs on its own event loop thread; do not assume single-threaded execution.