14 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). 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 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. - Update
ARCHITECTURE.mdto reflect the same. - Run the
/simplifyskill on changed code. - 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 withrender::draw_board; click-to-paint in Edit modeglyph_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 aconst fn; all other glyphs come from the map file. DerivesHashso boards can deduplicate glyphs into a palette.FontSpec— optional per-board bitmap font override:path: String,tile_w: u32,tile_h: u32. WhenNone, the app default font is used.Behavior— plain data struct of runtime behavioral properties:passable: bool,opaque: bool. Returned byArchetype::behavior(); new properties added here require no match arms elsewhere.Archetype(Copy,PartialEq) — enum of named element types:Empty,Wall,ErrorBlock. Each variant providesbehavior(),name()(used in map files), anddefault_glyph()(used by the editor when stamping a cell).ErrorBlockis a sentinel for unknown archetype names — renders as yellow?on red.ALL_ARCHETYPES: &[Archetype]— ordered list of valid editor choices (excludesErrorBlock).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>.cellsispub(crate).is_passable(x, y)checks objects before the grid cell: an impassable object blocks even over a passable floor tile.Player—x: i32, y: i32ObjectDef— scripted object placed on the board:x,y,glyph: Glyph,passable: bool,opaque: bool,script_name: Option<String>.passabledefaultsfalseandopaquedefaultstruein map files. Scripts not yet runtime-wired.PortalDef— parsed from map files, stored on Board; not yet runtime-wiredGameState— holdsboard: Board;try_move(dx, dy)checks passability before moving the player
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
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.
src/map_file.rs — map file loading:
MapFileand friends — serdeDeserializetypes for TOML map filesTileIndex—#[serde(untagged)]enum accepting eitherNum(u32)orChr(char); lets map files usetile = " "(char) ortile = 32(integer) interchangeablyFontHeader— deserializes the optional[font]section (path,tile_w,tile_h)impl From<MapFile> for Board— converts a parsed file into a ready-to-useBoard; unknown archetype names produceErrorBlockpub fn load(path: &str) -> Result<Board, Box<dyn std::error::Error>>— reads and converts a.tomlmap filepub fn save(board: &Board, path: &Path) -> Result<(), Box<dyn std::error::Error>>— serializesBoardback to TOML and writes to disk
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
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
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
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)
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
BehaviorandArchetypeare separate types —Archetypeis the named class of a thing (Wall,Empty,Object);Behavioris its runtime properties (passable,opaque). Adding a new property means adding a field toBehavior, 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.ArchetypeisCopyso this is efficient.- Archetypes are referenced by name in map files — so
ALL_ARCHETYPEScan be reordered or extended without breaking saved games. 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. - 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.