Files
kiln/CLAUDE.md
T
2026-06-03 17:45:43 -05:00

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

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.

Commands

cargo build          # compile
cargo run            # build and run (loads maps/start.toml)
cargo test           # run all tests
cargo test <name>    # run a single test by name (substring match)
cargo clippy         # lint
cargo fmt            # format

Architecture

The game is a single Rust binary using eframe 0.33 / egui 0.33 for the GUI. eframe drives a retained-mode UI: the App::update method is called every frame and is responsible for both drawing and responding to input.

update is structured in phases:

  • Input handling — arrow keys move the player (Play mode only)
  • egui::TopBottomPanel::top — menu bar (File → Exit, Play/Edit mode toggle)
  • editor::show_editor_panel — archetype palette panel (Edit mode only; declared before CentralPanel)
  • egui::CentralPanel::default — game viewport; rendered with render::draw_board; click-to-paint in Edit mode
  • glyph_picker::show — floating picker dialog (Edit mode only, when open)

Modules

src/game.rs — all core game types:

  • 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.
  • Playerx: i32, y: i32
  • 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. Scripts not yet runtime-wired.
  • PortalDef — parsed from map files, stored on Board; not yet runtime-wired
  • GameState — holds board: Board; try_move(dx, dy) checks passability before moving the player

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

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.

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

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

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

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

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]

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

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

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

[[objects]]          # optional; parsed but not yet runtime-wired
x = 10
y = 5
script = """
  on_touch(|| { send_message("open"); });
"""

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

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.

Key design decisions

  • Behavior and Archetype are separate typesArchetype 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.
  • 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.