Files
kiln/ARCHITECTURE.md
T

322 lines
23 KiB
Markdown
Raw Normal View History

2026-05-17 19:38:41 -05:00
# Kiln — Architecture & Design Notes
## What this is
2026-05-17 19:38:41 -05:00
Kiln is a **game-making system**, not just a game. The model is ZZT (1991, Epic MegaGames) — a DOS game that shipped with a built-in editor and a simple scripting language (ZZT-OOP), which let players create and share their own worlds. The goal here is something similar: a runtime + authoring environment where game worlds are defined in plain text files with embedded scripts, and the engine interprets them.
This document records the architectural decisions made so far and the reasoning behind them, so future development sessions don't have to rediscover the "why."
---
## Tech stack
| Concern | Choice | Reason |
|---------|--------|--------|
| GUI/windowing | eframe 0.33 / egui 0.33 | Pure Rust, retained-mode, works on desktop and (eventually) WASM |
| Scripting | Rhai 1.x | Pure Rust, sandboxed, WASM-compatible; designed for embedding |
| Map format | TOML + serde | Human-readable, good Rust tooling, standard in the ecosystem |
**WASM compatibility is a first-class requirement.** Everything in the stack must compile to WASM. This ruled out Lua (C FFI via mlua/rlua) for scripting. Rhai was chosen specifically because it is pure Rust with no C dependencies and explicit no_std/WASM support.
---
## Module structure
```
src/
main.rs — App, AppMode, frame loop (input → menu → panels → board → dialogs)
game.rs — core data types and game logic
map_file.rs — TOML deserialization, Board loader
2026-05-19 00:07:04 -05:00
render.rs — drawing primitives; cell size comes from BitmapFont
font.rs — BitmapFont, tile UV mapping, placeholder font
font_dialog.rs — floating font-picker dialog (path, tile dims, preview, apply)
editor.rs — EditorTab, EditorState, show_editor_panel
2026-05-19 00:07:04 -05:00
glyph_picker.rs — floating Glyph picker dialog; decoupled (open, glyph, font) interface
maps/
start.toml — the starting map (loaded at launch)
2026-05-19 00:07:04 -05:00
assets/
vga-font-8x16.png — default CP437 bitmap font (256×128 px, 32 cols × 8 rows, 8×16 tiles)
```
### `game.rs` — core types
The types here are the runtime representation of a game world. They are deliberately free of any file-loading or rendering concerns.
2026-05-19 00:07:04 -05:00
**`Glyph`** (`Copy`, `Eq`, `Hash`)
The visual representation of one cell: a `tile: u32` index into the active bitmap font, a foreground color, and a background color. Stored *per cell* (not per element type) so individual cells can animate or vary their appearance independently — e.g. a "fire" element where each flame tile has a slightly different color — without changing their behavior. Derives `Hash` so the glyph picker can deduplicate the board palette into a `HashSet<Glyph>`.
**`FontSpec`**
Optional per-board font override: `path: String`, `tile_w: u32`, `tile_h: u32`. Stored on `Board.font`. When `None`, `App`'s `default_font` is used. The editor's Board tab exposes a "Font…" button to change this at runtime.
**`Behavior`**
A plain data struct of runtime behavioral properties — currently `passable: bool` and `opaque: bool`. Returned by `Archetype::behavior()`. Adding a new property (e.g. `shootable`) requires only a new field here; no match arms needed at call sites. This is a deliberate improvement over storing behavior as a bag of booleans on a per-cell or per-archetype basis.
**`Archetype`**
An enum of named element types: `Empty`, `Wall`, `ErrorBlock`. Each variant knows:
- Its canonical map-file name (e.g. `"wall"`) via `name()`
- Its default `Behavior` via `behavior()`
- Its default `Glyph` via `default_glyph()` — used by the editor when stamping a cell
`ErrorBlock` is a sentinel for map files that reference an unknown archetype name. It renders as a yellow `?` on red so malformed maps are immediately visible in-game. It is excluded from `ALL_ARCHETYPES` (the editor's list) because it is not a valid authoring choice.
**Why `Behavior` and `Archetype` are separate types:**
`Archetype` is the *named class* of a thing — it lets you say "this is a wall" in a map file and in the editor. `Behavior` is the *runtime properties* that drive simulation. Keeping them separate means adding a new property (e.g. `pushable`) only requires a new field on `Behavior`; no match arms need to be updated across the codebase.
**Why Glyph and Archetype are separate:**
In ZZT, each board tile had both a visual (character + color pair) and an element type. The visual could vary per-tile even for the same element type. We replicate this: `Glyph` is the visual (per-cell), `Archetype` is the class (shared across cells of the same type). This lets you have a wall that's gray in one room and blue in another without creating two archetype variants.
**`Board`**
The complete unit of a game world — one "room" or "screen" in ZZT terminology. Holds:
- `cells: Vec<(Glyph, Archetype)>` — row-major grid; each cell directly owns its visual and behavioral class
- `player: Player` — current player position on this board
- `objects: Vec<ObjectDef>` — scripted objects (parsed; scripts not yet runtime-wired, but `passable`/`opaque` are live and affect collision)
- `portals: Vec<PortalDef>` — exits to other boards (parsed, not yet runtime-wired)
2026-05-19 00:07:04 -05:00
- `font: Option<FontSpec>` — per-board font override; `None` means use the app default
**`ObjectDef`**
A scripted object placed on the board. Holds `x`, `y`, `glyph: Glyph` (owned by the object so scripts can change its appearance), `passable: bool`, `opaque: bool`, and `script_name: Option<String>`. `passable` defaults `false` and `opaque` defaults `true` in map files (objects block by default). `Board::is_passable` checks objects before consulting the grid cell's `Archetype::behavior()`, so an impassable object blocks movement even when placed over a passable floor tile. The `passable` and `opaque` flags are editable via checkboxes in the editor's Objects tab.
**Why `cells` is `Vec<(Glyph, Archetype)>` with no palette:**
An earlier design had `cells: Vec<(Glyph, usize)>` where the `usize` indexed a per-board `elements: Vec<Element>` palette. This was eliminated because the palette indirection added complexity without benefit: `Archetype` is a `Copy` enum (4 bytes), so storing it per-cell is as efficient as storing an index, and it removes the invariant that the index must stay in sync with a specific board's palette.
**Why `Board` is the complete unit (no wrapper struct):**
An earlier design had `GameMap { board: Board, player: Player, ... }`. This was eliminated because the split was artificial: there's no meaningful use of a `Board` without a player position, and no meaningful use of a player without a `Board`. ZZT itself treats a board as containing everything — the grid, the objects, and the player entry point. Collapsing to a single struct matches the domain model.
**`GameState`**
Currently a thin wrapper around `Board` that provides game-logic methods (`try_move`). It exists to keep mutation logic (collision checking, movement) separate from the data. As the game grows, event processing and scripting dispatch will live here.
---
2026-05-19 00:07:04 -05:00
### `font.rs` — bitmap font
**`BitmapFont`** wraps an egui `TextureHandle` plus tile geometry (`tile_w`, `tile_h`, `img_w`, `img_h`). The texture is preprocessed at load time: the top-left pixel of the source PNG becomes the "background" sentinel — those pixels are stored as `TRANSPARENT`, all others as opaque `WHITE`. This lets egui's image tinting do all the colorization work at render time:
1. `painter.rect_filled(cell_rect, 0.0, glyph.bg)` — paint the background
2. `painter.image(texture_id, cell_rect, tile_uv, glyph.fg)` — draw the tile; egui multiplies each pixel by the tint color, so white → `fg` and transparent → nothing (revealing `bg`)
No custom shaders required.
**`tile_uv(tile)`** delegates to the private `compute_tile_uv` function which is unit-tested directly (no egui `Context` needed). `tile_cols()`, `tile_count()`, `img_w()`, `img_h()` are helpers used by the picker and dialog to lay out grids.
**`create_placeholder(ctx)`** generates a 16×16 grid of 8×8 tiles where each tile shows the 8 bits of its index as a column bar. Used as a fallback when `assets/vga-font-8x16.png` is missing.
---
### `font_dialog.rs` — font picker dialog
**`FontDialogState`** holds the in-progress edits: `path`, `tile_w`, `tile_h`, and a `preview: Option<BitmapFont>` loaded when the user clicks "Load". Separating dialog state from `FontSpec` means the user can explore settings without committing.
**`show(ctx, open, state, font_spec)`** renders a floating window with:
- A text field + "Browse…" button (`rfd::FileDialog`)
- `DragValue` controls for tile dimensions
- A "Load" button that decodes the PNG and stores it in `state.preview`
- A scrollable preview of the full tilesheet with grid overlay at tile boundaries
- "Apply" (enabled only after a successful Load) writes to `font_spec` and closes; "Use default" clears `font_spec`
Uses a `should_close: bool` flag outside the closure to work around egui's `.open(open)` borrow conflict.
---
### `map_file.rs` — file loading
**`MapFile`** and supporting structs are serde deserialization types only — they exist solely to parse TOML and are never used at runtime.
2026-05-19 00:07:04 -05:00
**`TileIndex`** is a `#[serde(untagged)]` enum (`Num(u32)` | `Chr(char)`) that accepts either an integer (`tile = 35`) or a single-character string (`tile = "#"`) in palette entries. This preserves backward compatibility with existing map files while allowing numeric indices in new ones.
**`FontHeader`** deserializes the optional `[font]` section and is converted to `FontSpec` in `From<MapFile> for Board`.
**`impl From<MapFile> for Board`** — the single conversion point. Reads the palette (each entry maps a character to a `(Glyph, Archetype)` pair), then walks the grid string character-by-character to build `cells`. Unknown archetype names produce an `ErrorBlock` cell and log a warning. This is the only place that knows about both the file format and the runtime representation.
**`pub fn load(path: &str) -> Result<Board, ...>`** — reads a file, deserializes, converts. Called from `main()` before the window is created.
**Why loading happens before window creation:**
eframe requires `NativeOptions` (including window size) to be set before calling `run_native`. Loading the board first means its dimensions are available if they're ever needed for sizing logic (currently the window uses fixed defaults, but the board must exist before `App::new` is called).
---
### `main.rs` — app + frame loop
2026-05-19 00:07:04 -05:00
`App` holds a `GameState`, an `AppMode` (`Play` | `Edit`), an `EditorState`, a `default_font: BitmapFont` (loaded from `assets/vga-font-8x16.png` at startup, falling back to `create_placeholder`), and a `board_font: Option<BitmapFont>` (loaded from `board.font` when a per-board font is set). `App::new` takes `&egui::Context` to upload textures before the first frame.
Font change detection: before calling `show_editor_panel`, `font_spec_before` is cloned; after the call, if `board.font` differs, `apply_font_spec` reloads `board_font`. The active font for the frame is `board_font.as_ref().unwrap_or(&default_font)`, extracted as a `let` binding before mutable borrows to satisfy the borrow checker.
The `update` method phases:
1. Reads arrow key input and calls `GameState::try_move`**Play mode only**
2. Draws the menu bar with a File menu and a Play/Edit mode toggle
3. In Edit mode: calls `editor::show_editor_panel` — declared before `CentralPanel` so egui allocates its space first
4. Draws the board and player via `render::draw_board` (see viewport sections below)
5. In Edit mode: calls `glyph_picker::show` if `editor.glyph_picker_open`
**Viewport — Play mode:**
`render::board_origin(available, board_w, board_h, player)` computes the pixel position of cell (0,0). If the board fits along an axis it is centered; if it overflows the viewport is centered on the player and clamped so no empty space appears at the board edges. No scroll bars.
**Viewport — Edit mode:**
The board is wrapped in `egui::ScrollArea::new([true, true])`, which shows scroll bars when the board overflows the viewport. Content is allocated at exact board size; `rect.min` from `allocate_exact_size` serves as the origin passed to `render::draw_board`.
**Stamp action:** `*board.get_mut(cx, cy) = (self.editor.glyph, self.editor.selected)` — the custom glyph and selected archetype are written together. The glyph can differ from the archetype's `default_glyph()` if the user has customized it.
---
2026-05-19 00:07:04 -05:00
### `render.rs` — drawing primitives
All pixel-level rendering knowledge lives here. No dependency on `EditorState` or `App`. Cell size is no longer a fixed constant — it comes from the active `BitmapFont`.
2026-05-19 00:07:04 -05:00
**Constants:** Window sizing (`DEFAULT_WINDOW_W = 840`, `DEFAULT_WINDOW_H = 524`, `MIN_WINDOW_W/H`) defined here. No `CELL_W`/`CELL_H` — those were removed when bitmap fonts were introduced.
2026-05-19 00:07:04 -05:00
**`paint_glyph(painter, rect, glyph, font)`** — fills `rect` with `glyph.bg`, then draws the tile image tinted by `glyph.fg`. Used by both `draw_glyph` and `glyph_picker`.
2026-05-19 00:07:04 -05:00
**`draw_glyph(painter, origin, x, y, glyph, font)`** — computes the cell rect from `font.tile_w/tile_h`, delegates to `paint_glyph`.
2026-05-19 00:07:04 -05:00
**`draw_board(painter, origin, board, font)`** — iterates all cells calling `draw_glyph`, then draws the player as an overlay on top.
**`draw_object_overlays(painter, origin, board, font, selected)`** — called from Edit mode when the Objects tab is active. Draws a dim border on every object cell and a bright border on the selected one, making objects discoverable without obscuring the underlying tile.
2026-05-19 00:07:04 -05:00
**`pos_to_cell(origin, pos, tile_w, tile_h) -> (i32, i32)`** — converts a pixel position to cell coordinates using floor division. Returns negative values for clicks above/left of the origin; callers must guard `>= 0`. Mirrors the rendering math so clicks land on the correct cell.
2026-05-19 00:07:04 -05:00
**`board_origin(available, board_w, board_h, player, font) -> Pos2`** — computes the pixel origin for Play-mode rendering (centering or player-tracking with edge clamping).
---
### `editor.rs` — editor state and side panel
**`EditorTab`** (`Palette` | `Board` | `Objects` | `World`) — which tab is active in the side panel.
**`EditorState`** — transient editor state:
- `selected: Archetype` — the archetype class to stamp
- `glyph: Glyph` — the visual to stamp (independent of the archetype's default; resets to `archetype.default_glyph()` when the archetype selection changes)
- `glyph_picker_open: bool` — whether the glyph picker dialog is visible
- `tab: EditorTab` — which side-panel tab is active
2026-05-19 00:07:04 -05:00
- `font_dialog_open: bool` — whether the font picker dialog is visible
- `font_dialog_state: FontDialogState` — in-progress edits for the font dialog
- `selected_object: Option<usize>` — index into `board.objects` of the currently selected object
- `object_glyph_picker_open: bool` — whether the object glyph picker is open
- `object_editing_glyph: Glyph` — local copy of the selected object's glyph; written back to `board.objects[i].glyph` every frame
- `placing_object: bool` — when `true`, the next board click creates a new `ObjectDef` at that cell
**`show_editor_panel(ctx, editor, board, active_font)`** — renders the resizable right-side panel (default 200 px). Palette tab: scrollable archetype list and glyph preview button. Board tab: current font path label + "Font…" button. Objects tab: "Add Object" button that enters placement mode; lists objects by coordinate with click-to-select; for the selected object shows glyph preview, **Passable** and **Opaque** checkboxes (writing directly to `board.objects[i]`), and a script combobox. World tab: placeholder.
---
### `glyph_picker.rs` — glyph picker dialog
2026-05-19 00:07:04 -05:00
**`show(ctx, open, glyph, board_cells, font)`** — renders a floating `egui::Window`. Takes `open: &mut bool` and `glyph: &mut Glyph` directly, with no dependency on `EditorState`, so it can be invoked from any context that needs glyph selection.
Three sections:
2026-05-19 00:07:04 -05:00
1. **Board palette** — unique glyphs found on the current board (deduplicated via `HashSet<Glyph>`), shown as clickable cells rendered with `paint_glyph`. The current glyph is highlighted with a white stroke.
2. **Colors**`color_edit_button_srgba` pickers for `glyph.fg` and `glyph.bg`.
2026-05-19 00:07:04 -05:00
3. **Tile grid** — all tiles from the active `BitmapFont`, displayed at `tile_w × tile_h` per cell in a scrollable area. Column count matches the font image layout. Clicking a cell updates `glyph.tile`. The current tile is highlighted.
---
## Map file format
XPM-inspired (XPM is an old X11 image format that uses a character palette to define pixel colors). A `[palette]` section maps single characters to both a `Glyph` (visual) and an `Element` (behavior). The `[grid] content` is a TOML multi-line string where each character is a palette key.
2026-05-19 00:07:04 -05:00
**Keep this example in sync with `map_file.rs` whenever the format changes.**
```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
[palette]
2026-05-19 00:07:04 -05:00
" " = { archetype = "empty", tile = " ", fg = "#000000", bg = "#000000" }
"#" = { archetype = "wall", tile = 35, fg = "#808080", bg = "#606060" }
[grid]
content = """
############################################################
# #
############################################################
"""
[[objects]]
x = 10
y = 5
script = """
on_touch(|| { send_message("open"); });
"""
[[portals]]
x = 59
y = 12
target_map = "cave"
target_entry = "west_door"
```
**Why TOML over a custom format:**
The `toml` crate gives us deserialization with minimal code. Multi-line strings for the grid give a visual representation of the map. Embedded Rhai scripts fit naturally in TOML multi-line strings without escaping issues.
2026-05-19 00:07:04 -05:00
**Why `tile` accepts both char and integer:**
`TileIndex` is a `#[serde(untagged)]` enum. Old map files using `tile = " "` (a single-character string) continue to work unchanged. New map files can use `tile = 32` (integer) for clarity or when the tile has no printable character equivalent.
**Why archetypes by name, not by property:**
Palette entries used to store `passable = true/false` directly. Switching to `archetype = "wall"` means adding a new behavioral property (e.g. `opaque`) only requires a code change — existing map files don't need to be updated. It also makes files more readable: `archetype = "wall"` is self-documenting. Unknown names produce a visible `ErrorBlock` cell rather than silently defaulting.
**Why the palette approach:**
A direct mapping from palette character → `(Glyph, Archetype)` means the map file is both human-readable (you can see the shape of the room from the grid string) and flexible (per-cell visual variation is possible by using different palette chars with the same archetype but different colors).
**Colors** are `"#RRGGBB"` hex strings — universally understood, hand-editable.
**`player_start`** is a header field, not a palette character. The player is not a board cell; they are an entity that moves over the board. Using a palette character for player start (like `@` in many roguelikes) would mean the tile under the player is always that character, which makes it awkward to place a player over different terrain.
---
## What's not yet implemented
**Object scripting**`ObjectDef` and `PortalDef` are parsed from map files and stored on `Board`, but they have no runtime effect yet. The next step here is:
1. Wire `ObjectDef` scripts to Rhai: when the player moves to an object's cell, fire its `on_touch` handler
2. Define the Rhai API surface (what functions scripts can call: `send_message`, movement, board queries)
**Object behavior from scripts**`ObjectDef.passable` and `ObjectDef.opaque` are live and editable in the editor, but they are static author-set values. Eventually they should be overridable by the object's Rhai script at runtime (e.g. a door script that flips `passable` when opened). `Board::is_passable` already has the right shape for this; the script runtime just needs to be wired in.
**Portal navigation**`PortalDef` stores a `target_map` and `target_entry` but there's no multi-board loading or board switching yet.
**Multi-board world** — right now the engine loads a single `maps/start.toml`. Future: a world file or directory of boards, lazy-loaded as the player moves through portals.
2026-05-19 00:07:04 -05:00
**File open dialog** — the editor has no way to load a different map file from the UI. `rfd` is already a dependency (used by the font dialog), so a "Open map…" button is straightforward to add. WASM support for `rfd` file dialogs is an open question.
**Map save** — the editor can paint cells but has no way to write changes back to a `.toml` file. Saving will require serializing the `Board` back to `MapFile` format and writing it to disk.
---
## Future considerations
These are not current requirements but intended future directions. Where a planned change conflicts with current design, the tension is called out explicitly so it can be addressed before it becomes a problem.
### The player may become an object; boards may have no player
The long-term goal is for the "player" to be an object on the board that happens to respond to arrow key events — not a hardcoded special entity. Some boards may have no player-like object at all and do something else with input events (a cutscene, a menu, a puzzle that reacts to keys differently).
ZZT hard-coded the player as a special element and many game authors had to work around this limitation (e.g. hiding the real player behind a wall and scripting a fake one). This is a deliberate improvement over that model.
**Current design tension:**
The following assumptions are baked in today and will need to change when this is implemented:
- `Board.player: Player` is a required non-optional field. A board with no player can't be represented. This should eventually become `Option<Player>`, or the player should be removed from `Board` entirely and tracked by the engine layer only when present.
- `player_start` in the map file is a required header field. It will need to become optional, or player spawning will move into the object/script system (an object with a special role, spawned at its `x`/`y` position).
- `GameState::try_move` directly mutates `board.player`. Once the player is an object driven by Rhai, movement will go through the scripting dispatch layer instead. `try_move` will likely be replaced by something like `engine.dispatch_event(ArrowKey(dx, dy))`.
- In `main.rs`, the player is rendered as a hardcoded overlay using `Glyph::player()`. Once the player is an object, it should be rendered as part of the normal object layer, not as a special case.
None of these are blockers for current work, but avoid making `Board.player` more central than it already is (e.g. don't add methods that assume player presence, don't derive window sizing from player position).
---
## ZZT reference
ZZT (1991) was a text-mode game for DOS. Its playfield was 60×25 characters (the right 20 columns were the stats panel). Each board was a self-contained screen with objects (tiles with embedded ZZT-OOP scripts), passageways to adjacent boards, and a fixed element type system (about 50 built-in element types). Players could create worlds with the built-in editor and share `.ZZT` files.
2026-05-17 19:38:41 -05:00
Kiln takes the core ideas — tile-based boards, embedded scripts per object, named portals between boards — and rebuilds them in a modern, WASM-capable stack with a more flexible scripting language and a human-readable file format.