Files
kiln/kiln-core/CLAUDE.md
T
2026-07-10 23:16:28 -05:00

125 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# kiln-core
Module reference for the `kiln-core` crate — 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. See the
repository-root `CLAUDE.md` for project-wide guidance, code style, the world
file format, and key design decisions.
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 a `const fn` (tile 64 `@`, white on dark blue); all other glyphs come from map files. `Hash` is hand-implemented via packed `u32` representations so it stays in sync with `Eq`.
**`kiln-core/src/cp437.rs`** — tile-index → character mapping (`pub`):
- `CP437: [char; 256]` — full code-page-437 table (graphic glyphs for 0x000x1F, box-drawing for 0xB00xDF, etc.). `tile_to_char(tile: u32) -> char` indexes it for `tile < 256`; falls back to `char::from_u32` then a blank space. Unit-tested. Lives in core (not a front-end) because it is the *meaning* of a tile index under the default font; used by kiln-tui's renderer (`render.rs`) and kiln-ui's glyph picker alike.
**`kiln-core/src/colors.rs`** — the named color palette (`pub`):
- `NAMED_COLORS: [(&str, Rgba8); 16]` — the 16 EGA/VGA palette colors as `(name, color)` pairs in palette order (`Black`, `Blue`, …, `White`). The **single source of truth**: `script::register_global_constants` builds the Rhai `Black`/`Blue`/… `"#RRGGBB"` constants from it, and kiln-ui's glyph picker builds its named-swatch strips from it, so the two never drift.
**`kiln-core/src/archetype.rs`** — element taxonomy:
- `Archetype` (`Copy`, `PartialEq`, `Hash`) — enum of named element types: `Empty`, `Wall`, `Crate`, `HCrate`, `VCrate`, `Builtin(Builtin, &'static str)`, `ErrorBlock`. Each variant provides `behavior()`, `name()` (used in map files), and `default_glyph()`. `Crate` pushable any direction (CP437 ■, char 254); `HCrate`/`VCrate` pushable east/west (↔, char 29) / north/south (↕, char 18) only. `ErrorBlock` is a sentinel for unknown archetype names (yellow `?` on red). `TryFrom<&str>` checks the `Builtin` registry first (via `Builtin::from_name`), then the hard-coded terrain names; unrecognized names return `Err` (the map loader substitutes `ErrorBlock`).
- `Builtin` (`Copy`, `PartialEq`, `Hash`) — the family enum for all script-backed archetypes: `Gem`, `Heart`, `Pusher`, `Spinner`, `Key`. Generated by the `builtins!` macro (see below) along with all its methods. `Archetype::Builtin(family, alias)` carries both the family (selects the behavior and script source) and the specific alias matched during parsing (e.g. `"pusher_north"` or `"spinner_cw"`); the alias drives the per-alias glyph, the `BUILTIN_<alias>` tag on the expanded object, and the compile-cache key.
- `macro_rules! builtins!` — the declarative registry for script-backed archetypes. Each entry: `Variant => ["alias" => tile, …] { behavior, fg, bg, script: include_str!(…) }`. The macro generates `enum Builtin { … }` and `impl Builtin { from_name, behavior, default_glyph_for, script }`. **To add a new builtin**: add one entry here + write `src/scripts/<name>.rhai`. No other code changes are needed — `TryFrom<&str>`, `behavior()`, `name()`, `default_glyph()`, the expansion pass, and the save round-trip all derive from the macro output. Current entries: `Gem` (`"gem"` → tile 4, blue ♦, solid + pushable + grab), `Heart` (`"heart"` → tile 3, red ♥, solid + pushable + grab — restores 1 health on grab), `Pusher` (`"pusher_north"` → 30, `"pusher_south"` → 31, `"pusher_east"` → 16, `"pusher_west"` → 17; gray arrow tiles, solid + unpushable), `Spinner` (`"spinner_cw"` → 47 `/`, `"spinner_ccw"` → 92 `\`; gray, solid + unpushable), `Transporter` (`"transporter_north|south|east|west"` → 94/118/41/40 `^`/`v`/`)`/`(`; cyan, solid + **see-through** (`opaque: false`) + unpushable — animates a 4-frame loop and, on a front bump, moves whatever solid presses into its entrance — the player, an object, or a **pushed crate** — via a coordinate `shift`, either onto the cell just past it or, if that is blocked, out the far side of the nearest paired opposite-facing transporter). All aliases in a family share one Rhai script; the script reads `me.has_tag("BUILTIN_<alias>")` to determine per-instance direction/chirality.
**`kiln-core/src/builtin_scripts.rs`** — tag helpers for script-backed archetypes (`pub(crate)`):
- `BUILTIN_TAG_PREFIX` (`"BUILTIN_"`), `builtin_tag(arch) -> String`, `archetype_from_builtin_tag(tag) -> Option<Archetype>` — the tag convention. `expand_builtin_archetypes` tags each expanded object with `BUILTIN_<alias>` (e.g. `"BUILTIN_pusher_east"`) and also uses this string as the object's `script_name` compile-cache key (so each alias gets its own cached AST, though the Rhai source is the same for all aliases in a family). The script reads its tag to determine per-instance direction. `map_file::save` uses `archetype_from_builtin_tag` to collapse the object back into its archetype keyword so maps round-trip. The script sources and behavior definitions now live in `archetype.rs` via the `builtins!` macro (via `include_str!` of `scripts/*.rhai`); this file has only the three tag helpers.
**`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 from `Archetype::behavior()`: `solid: bool`, `opaque: bool`, `pushable: Pushable`, `grab: bool` (the player walking onto a `solid + grab` thing isn't blocked — it moves onto it and the thing's `grab()` hook fires; this is the *only* grab trigger; `Gem` and `Heart` both set the flag).
- `ObjectId = u32` — stable identifier for board objects.
- `Solid` — the single solid occupant of a cell, returned by `Board::solid_at`: `Player`, `Terrain { glyph, arch }` (a grid cell), or `Object(ObjectId)`. `place(board, x, y)` relocates it (terrain rewrites the destination grid cell). No layer index — the board is one grid.
- `PlayerPos { x: i32, y: i32 }` — the player's current position on a board (held as `Board::player`). The broader player *state* (health, gems, keys) lives in [`player::Player`], not here.
- `PortalDef { name, x, y, target_map, target_entry }` — parsed from map files; runtime-wired (portals switch boards). No `z`.
- `LogSink(Rc<RefCell<Vec<LogLine>>>)` — the shared, immediate log channel handed to every `Registerable::register` and the write API. `line(LogLine)` / `error(String)` push straight onto it during hook execution (so script `log()` output and errors are *not* paced by the object's action queue); `take()` drains it. `GameState::drain_log` empties it into `GameState::log` each frame.
**`kiln-core/src/object_def.rs`** — scripted objects (`pub(crate)`):
- `ObjectDef` — a scripted tile: `id: ObjectId` (its stable id, assigned when the object is added to a board), `x`, `y`, `glyph: Glyph`, `queue: ObjQueue` (its private output queue of pending [`Action`]s, drained into a batch of actions that `GameState` applies immediately after the object's hook — see `api::queue`), `solid: bool` (default `true`), `opaque: bool` (default `true`), `pushable: bool` (default `false`), `grab: bool` (default `false`; set when a grab archetype like a gem is expanded — walking onto it fires `grab()`), `script_name: Option<String>`, `builtin_script: Option<&'static str>` (embedded source set when a script-backed archetype like a pusher is expanded at load; the expansion also sets `script_name` to a synthetic `BUILTIN_*` compile-key, so `builtin_script` supplies the *source* while `script_name` is the *key*; not serialized — see [`builtin_scripts`]), `tags: HashSet<String>`, `name: Option<String>`. A **trigger** is just an `ObjectDef` that is non-solid, non-opaque, non-pushable, glyphless (transparent glyph) and scripted (authored via `[[triggers]]`). The optional `name` is validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared to `None` (nonfatal). `ObjectDef::default_glyph()` returns tile 63 (`?`) yellow on black.
**`kiln-core/src/layer.rs`** — the board grid (palette + char map load unit; file/type name kept for history):
- `GridCell = (Glyph, Archetype)` (`pub(crate)`) — one grid cell: its visual and its behavioral class. A cell whose `glyph.tile == 0` (`Glyph::transparent()`) draws nothing, so the floor/decoration beneath shows through; solidity comes from the archetype, independent of transparency. (There is no longer a `Layer` type — the board is a single `Vec<GridCell>`.)
- `GridData { content, fill, sparse, palette: HashMap<String, PaletteEntry> }` — serde for the single `[grid]` block. The char map comes from exactly one of three optional fields (precedence `content``fill``sparse`; none ⇒ all spaces): `content` (a multi-line grid string), `fill` (one char filling the whole grid), or `sparse` (a `Vec<SparseCell>` of `{ x, y, ch }` over an otherwise all-spaces grid). Only `content` can mismatch the board dims (hard error); `fill`/`sparse` are always exactly sized (a bad `fill`/`ch` or out-of-bounds `sparse` cell is a nonfatal error). `PaletteEntry` is a single flat struct with a `kind: String` discriminator plus all-optional fields (`tile`, `fg`, `bg`, `solid`, `opaque`, `pushable`, `script_name`, `tags`, `name`, `target_map`, `target_entry`); only the fields relevant to the kind are read. (`floor` is **not** a grid kind — the floor is a board attribute; see `floor.rs`/`map_file.rs`.)
- `build_grid(data, w, h, &mut Vec<LogLine>) -> Result<(Vec<GridCell>, Vec<Placement>), String>` — resolves the palette (via `resolve_entry`), validates the grid dims (the only hard `Err`), and walks the grid building the cell vec plus a `Vec<Placement>` (`Object(ObjectTemplate, x, y)` / `Portal(PortalTemplate, x, y)` / `Player(x, y)`) for the map loader to resolve.
- `resolve_entry` maps `kind``Resolved`: `empty` → transparent cell; `object`/`portal`/`player` → a placement; any other string → `Archetype::try_from` (`Ok` → terrain cell; `Err` → visible `ErrorBlock` + logged error). A `portal` missing `name`/`target_map`/`target_entry` is dropped to a transparent cell with an error.
**`kiln-core/src/board.rs`** — the board data type:
- `Board` — the complete game unit (ZZT-style "board"): `width`, `height`, `grid: Vec<(Glyph, Archetype)>` (the single row-major cell grid; `pub(crate)`), `floor: Floor` (the cosmetic floor attribute — blank / fixed glyph / biome; `pub(crate)`), `decorations: Vec<Decoration>` (`pub(crate)`), `player: Player`, `objects: BTreeMap<ObjectId, ObjectDef>`, `next_object_id: ObjectId`, `portals: Vec<PortalDef>`, `board_script_name: Option<String>`, `load_errors: Vec<LogLine>` (`pub(crate)`), `registry: HashMap<String, RegistryValue>`. Scripts are **not** stored on `Board` — they live in [`World::scripts`].
- `Decoration { x, y, glyph, archetype }` (`pub`) — a non-solid `(glyph, archetype)` placed off the grid, drawn only where the grid cell is empty. Normally empty; populated by save files (a runtime solid can end up over a non-solid, which is recorded here). A solid archetype is rejected at load.
- `get(x, y) -> &(Glyph, Archetype)` / `get_mut(x, y)` — grid cell access (panics OOB). (No `z` / `layer_count` — the board is one grid.)
- `glyph_at(x, y) -> Glyph` — the glyph a renderer should display at `(x, y)`, by fixed precedence: the player (`Glyph::player()`); then an object on the cell (a solid object always, else the first visible non-solid); then the grid cell (a solid always, else a non-transparent glyph); then a portal; then a `decoration` (only reached because the grid cell was empty); then `floor.glyph_at(x, y, width)`; then the canonical black `Empty` glyph. 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 a solid object, then the grid cell's archetype if solid).
- `is_passable(x, y)` — convenience inverse of `solid_at`.
- `can_push(x, y, dir) -> bool` — read-only: does the chain of pushable solids end in open space?
- `bump_target(x, y, dir) -> Option<ObjectId>` — the object a move **into** `(x, y)` heading `dir` bumps: the solid object in the target cell, or the one at the end of a chain of pushed crates (walked through in `dir`). Open space, the player, or a wall yield `None`. This is what lets a plain crate — not just an object or the player — trigger a `bump`. Used by `GameState::try_move` / `step_object`; the caller supplies the came-from direction as `dir.opposite()`.
- `can_shift(x, y, dir) -> bool` — read-only, one cell ahead only: `(x, y)` holds a pushable *and* the next cell is empty or another pushable (does **not** require the chain to end in open space, unlike `can_push`). The **player** counts as a blocker for this check. A Rust-side companion read to `apply_shift`; not itself exposed to scripts.
- `push(x, y, dir)` — mutating: shoves the chain one step, leaving a transparent cell behind (so the floor is revealed). A pushed solid moves within the grid.
- `add_object(obj) -> ObjectId`, `remove_object(id) -> Option<ObjectDef>`, `object_ids_at(x, y)`, `solid_object_id_at(x, y)` — object add/remove + queries.
- `grab_object_at(x, y) -> Option<ObjectId>` — a solid `grab` object on the cell (the player-walks-onto-it case). `GameState::try_move` uses it to fire `grab()` instead of bumping when the player steps onto a grab thing. Grab is **only** a player-movement event: a grab thing pushed/shifted into the player is treated as an ordinary solid (no special-case). (`remove_object` deletes the object and its `ObjQueue` from the `objects` map; a live `ScriptHost` keeps an unused `Scope` for that id until it is rebuilt — harmless, since hook dispatch iterates the board's *current* ids.)
- `clear_solid(x, y)` — replaces the grid cell's solid terrain (if any) with a transparent `Empty`, revealing the floor. `apply_shift(cells: &[(i32, i32)]) -> Vec<LogLine>` — rotates the solids occupying a ring of cells one step: each cell's solid moves to the next coordinate in the list, and the last wraps back to the first. Backs the script `shift()` fn. A cell is **immobile** if its solid is unpushable, or is an `HCrate`/`VCrate` asked to move along its forbidden axis; immobility **cascades backward** along the ring, so a blocked run stays put while the free tail still rotates. Movable cells are cleared, then each mover is `place`d at its target (a displaced object is despawned via `remove_object`). Any out-of-bounds coordinate **rejects the whole shift**. The player rotates like any other solid.
- `place_archetype(x, y, arch, glyph)` — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving the floor untouched: a non-`Empty` (solid) arch removes a solid object in the cell then writes `(glyph, arch)` into the grid cell; `Empty` erases — removes every object plus the grid cell (→ transparent `Empty`). Note it writes **terrain** even for script-backed archetypes (pushers/spinners), so the editor stamps an inert cell — see `expand_builtin_archetypes`.
- `expand_builtin_archetypes()` — the **single** expansion point: replaces every `Archetype::Builtin(b, alias)` grid cell with the scripted object it expands to. For each such cell: vacates the grid cell (→ transparent `Empty`), calls `b.script()` for the embedded source, uses `builtin_tag(arch)` as both the `BUILTIN_<alias>` tag and the `script_name` compile-cache key, and copies the cell's glyph (so palette overrides survive). The object's `pushable`/`grab` flags come from `b.behavior()`. Idempotent (vacated cells become `Archetype::Empty`, so a second pass finds nothing). Called by `TryFrom<MapFile> for Board` after cross-cell validation (disk loads) and again by the editor's `playtest()` on the `World::deep_clone`. Builtin objects get ids after hand-placed ones (reading order among themselves).
- `in_bounds((i32, i32))` — bounds-checks a possibly-negative coord.
- `is_valid()` / `load_errors()` / `report_error(msg)` — nonfatal load-error surface.
**`kiln-core/src/player.rs`** — game-global player state (`pub`):
- `Player { health: i64, max_health: i64, keys: Keyring, gems: i64 }` — the player's persistent stats, owned by `GameState::player` (separate from the per-board `PlayerPos` position in `utils.rs`). `Default` starts `health = max_health = 5`, `gems = 0`, no keys. `alter_gems(delta) -> bool` adds to `gems` but refuses (returns `false`, no change) if it would go negative. `alter_health(delta)` adds to `health` clamped to `[0, max_health]`. A `Copy` snapshot is handed to each script hook and exposed to Rhai (read-only) as the `Player` constant.
**`kiln-core/src/keys.rs`** — key inventory and colors (`pub`):
- `KeyType` (`Red`/`Orange`/`Yellow`/`Green`/`Blue`/`Cyan`/`Purple`/`White`) — the eight key colors. `glyph() -> Glyph` returns the key tile (12, `♀`) in that color's fg — the single source of truth for key colors, used by `Keyring::colors` and by kiln-tui's editor menu. (Moved here from `game.rs`; `archetype.rs`'s `Key` builtin aliases must match these glyphs — see its `key_aliases_have_distinct_fg_colors` test.)
- `Keyring { red, orange, yellow, green, blue, cyan, purple, white: bool }` — the player's key inventory (one bool per color), held by `Player::keys`. `set_by_name(name, value) -> bool` sets a slot by color name (returns `false` for an unknown name); `colors() -> [(bool, Rgba8); 8]` lists all eight in display order paired with their render color.
**`kiln-core/src/game.rs`** — game-loop logic only:
- `SpeechBubble { object_id, text, remaining }` — an active speech bubble created by a script's `say(s)` call. `remaining` counts down in `GameState::tick`; when it reaches zero the bubble is removed. At most one bubble per object (a new `say()` 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's `scroll(lines)` call. Lives on `GameState::active_scroll: Option<Scroll>`; front-ends pause ticks while it's `Some` and close it via `close_scroll(choice)`. `ScrollLine` is re-exported from `action.rs` through `game.rs` so front-ends import both from `kiln_core::game`.
- `GameState` — owns `world: World` (all boards as `Rc<RefCell<Board>>` + world scripts) and `current_board_name: String` (key of the active board). `pub log: Vec<LogLine>`, `scripts: ScriptHost`, `pub speech_bubbles: Vec<SpeechBubble>`, `pub active_scroll: Option<Scroll>`, and `pub player: Player` — the game-global player state ([`player::Player`]: `health`/`max_health`/`gems`/`keys`) that persists across board transitions (it is *not* per-board; `Board::player` holds only the per-board `PlayerPos`). `player.gems` is changed at runtime by the `alter_gems(n)` script fn (e.g. grabbing a gem); `player.health` is changed by `alter_health(dh)` (clamped to `[0, max_health]`, e.g. grabbing a heart); `player.keys` is changed by `set_key(color, present)`. Each script-hook call is handed a `ScriptState` bundle — a `Copy` snapshot of `player` plus a shared handle to the active board — passed to the hook as its `state` parameter (Rhai type `State`, read via `state.player`/`state.board`; see `api::state`). Front-ends reach the active board through `board() -> Ref<Board>` / `board_mut() -> RefMut<Board>` (both look up `world.boards[current_board_name]`). `current_board_name() -> &str` returns the active key. **`from_world(world: World) -> Self`** is the primary constructor: clones the start board's `Rc<RefCell<Board>>` for the `ScriptHost`, compiles scripts from `world.scripts`. `new(board)` and `with_scripts(board, scripts)` are `#[cfg(test)]`-only sugar that build a minimal single-board `World`. `try_move(dir)` moves the player (pushing if possible) and fires `bump(dir_from)` on the solid object it presses into — directly or at the end of a chain of crates it shoves (see `Board::bump_target`), where `dir_from` is the side the bump came from; walking onto a `grab` thing (via `Board::grab_object_at`) instead moves onto it and fires `grab()` — applied immediately — so its `die()`/`alter_gems()`/`alter_health()` take effect before the call returns (no player+object overlap). **`try_move` is the only grab trigger** — a grab thing pushed or swapped into the player is an ordinary solid (it slides/blocks/refuses like any other). `run_init()` runs object `init()` hooks once at startup. `tick(dt)` expires speech bubbles, then runs each object's `tick(dt)` hook **in ascending id order, applying that object's drained actions before the next object runs** — so a later object sees the board an earlier one just changed (the requested fix; the exception is actions still behind a `Delay`). The workhorse is `apply_actions(Vec<BoardAction>) -> Events`: two-phase (mutate the board collecting `(bumped, dir_from)` pairs, drop the borrow, then apply the player-stat / bubble / scroll changes), applying each `Action` (`SetTile`/`SetColor` → source glyph, `Move(dir)``step_object`, `Say``speech_bubbles`, `Scroll``active_scroll`, `AddGems(n)``player.gems`, `AlterHealth(dh)``player.health` clamped to `[0, max_health]`, `SetKey``player.keys`, `Shift``apply_shift`, `Push`/`Teleport` → board moves, `Die``remove_object(source)`). It **returns** the `bump`/`send` reactions rather than firing them; after all object hooks, `settle(events, called)` runs them (and any they cascade into) until quiescent, applying each hook's actions as it goes. `called: CalledSet` (a `HashSet<(ObjectId, fn/hook name, arg repr)>`, fresh per `tick`/`try_move`/`run_init`) records every reaction fired and skips repeats, so a bump/send cycle terminates. `drain_log()` moves the script host's collected log lines — script `log()` output and errors — into `log`. `close_scroll(choice)` clears `active_scroll` and optionally fires `run_send(source, choice, None)` to dispatch the player's selection back to the object.
**`kiln-core/src/action.rs`** — the `Action` enum and its supporting types:
- `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 a `Scroll` action: `Text(String)` (plain, word-wrapped) or `Choice { choice, display }` (selectable; `choice` is sent back to the source object when the player picks it).
- `SendArg` — the optional argument carried by a `Send` action / `send()` call: `None`/`Int`/`Float`/`String`, with `From<Dynamic>`/`Into<Dynamic>` conversions.
- `Action` (`pub(crate)`) — deferred mutation emitted by a script, drained from the issuing object's queue and applied by `GameState::apply_actions`: `Move(Direction)`, `SetTile(u32)`, `SetTag { target, tag, present }`, `Say(String, f64)` (bubble text + duration), `Delay(f64)` (never drained as an action — consumed by `ObjQueue::drain` to pace the object), `SetColor { fg, bg }`, `Send { target, fn_name, arg }`, `Scroll(Vec<ScrollLine>)`, `Teleport { x, y }`, `Push { x, y, dir }` (shove a chain at arbitrary coords; zero time cost), `Shift(Vec<(i64,i64)>)` (rotate the solids on a ring of cells one step; zero time cost), `AddGems(i64)` (change `GameState::player.gems`, clamped at 0; zero cost), `AlterHealth(i64)` (change `GameState::player.health`, clamped to `[0, max_health]`; zero cost), `SetKey(String, bool)` (give/take a named player key color; zero cost), `Die` (remove the source object; zero cost).
- `BoardAction { source, action }` — an `Action` drained from an object's queue, tagged with the `ObjectId` that issued it; `GameState` applies a `Vec<BoardAction>` per object.
**`kiln-core/src/floor.rs`** — the board floor attribute + procedural generators:
- `Floor` (`pub`) — a board's cosmetic floor, drawn beneath everything (replaces the old floor *layer*): `Blank` (the canonical black empty shows), `Fixed(Glyph)` (one glyph tiled across the board), or `Biome { generator, glyphs }` (a procedural texture — keeps its `generator` so save re-emits the name, plus a per-cell `glyphs` buffer pre-rolled once at load from `FLOOR_SEED`). `Floor::biome(gen, w, h)` builds and pre-rolls a biome; `Floor::glyph_at(x, y, width) -> Option<Glyph>` is the per-cell lookup used by `Board::glyph_at`; `Default` is `Blank`. Resolved from the map-file `floor = { … }` attribute in `map_file::FloorSpec::resolve`.
- `FloorGenerator` (`Grass`/`Dirt`/`Stone`) — procedural textures differing only in color scheme and texture-char probability. `from_name(&str)` parses the map-file name and `name()` is its inverse (for save); `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 `. ,`). Uses `tinyrand` (already a workspace dep, WASM-safe). `FLOOR_SEED` (`pub(crate)`) seeds one `StdRand` per biome build, so floors are deterministic/testable and depend only on generator + dimensions.
**`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()`, `append()`, and `LogLine::error()` (a red-on-black single-span constructor used for nonfatal load errors) build messages; each front-end converts a `LogLine` to its own styled text at render time.
**`kiln-core/src/script.rs`** — Rhai scripting **host** (the script-facing types live in `kiln-core/src/api/`, below). The script-author's reference is [`docs/script-api.md`](../docs/script-api.md).
- `Registerable` trait — `fn register(engine, log_sink)`: a type's hook for installing itself (Rhai type name + getters/methods) on the `Engine`. Implemented by every `api` type plus `Glyph`/`Keyring`.
- `ScriptHost` — owns the Rhai `Engine`, the compiled scripts (`HashMap<String, CompiledScript>`), one persistent `Scope` per scripted object (`HashMap<ObjectId, Scope>`), and the shared `LogSink`. (There is no shared board queue: each hook call drains into a local `Vec<BoardAction>` that it returns to `GameState`.) Built with `ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>)`: the first arg is a shared ref to the active board (cloned into the write-API closures and each scope's `Registry`), the second is the world-level script pool. Each script is compiled once per `script_key` (the object's `script_name` — a world-pool name, or a synthetic `BUILTIN_*` name assigned by `expand_builtin_archetypes` so identical built-ins share one AST); the source is the pool entry for that key, or the object's embedded `builtin_script`. `CompiledScript` records which hooks the AST defines (`has_init`/`has_tick`/`has_bump`/`has_grab`). **Per-object output queues no longer live here** — each `ObjectDef` owns its `queue: ObjQueue`. Reports compile/unknown-script failures onto the `LogSink`; runs nothing.
- Lifecycle hooks, each taking `me` (an `ObjectInfo`) and `state` (a `ScriptState`) before any hook-specific arg: `init(me, state)`, `tick(me, state, dt)`, `bump(me, dir)` (`dir` is the `Direction` the bump came *from*, fired when any solid — the player, another object, or a pushed crate — presses into this object), and `grab(me, state)` (fired when the player walks onto a `grab` thing — the only grab trigger). All optional — detected via `AST::iter_functions()` by name **and arity** (bump is arity 2). Run one object at a time (the per-object loop lives in `GameState`): `run_tick_on(id, dt)` / `run_init_on(id)` / `run_bump(id, dir)` / `run_grab(id)` / `run_send(id, fn, arg)`, all funneling through `run_hook_on_one` and **returning the `Vec<BoardAction>` they drained** for `GameState` to apply. `run_hook_on_one` builds a fresh `ObjectInfo`, pushes `[me, state, (arg)]`, and calls the hook tagged with the object's id. After the call — **whether or not the hook is defined** — it always drains the object's queue, so a delay still advances on an object that has no `tick`. Runtime errors go to the shared `LogSink` (drained to the log), not fatal.
- `run_send(state, id, fn_name, arg)` — calls an arbitrary named function on an object (backs the `send()` action and scroll-choice dispatch). Picks the param list by the function's arity: 3 → `(me, state, arg)`, 2 → `(me, state)`, 1 → `(arg)`, 0 → `()`; a missing arg is `Dynamic::UNIT`. Errors if no function of that name exists.
- **Reads** are methods/getters on the `api` types handed to the hook — `state.player.*`, `state.board.*`, `me.*` (see the `api/` section). There are **no read free functions** anymore.
- **Write API** (`register_write_api`) — free functions that (mostly) enqueue an `Action` onto the **issuing object's** `queue` (resolved from the per-call tag): `move(dir)` (enqueues `Move` then a `MOVE_COST` delay), `delay(secs)`/`now()` (queue pacing), `set_tile(n)`, `set_fg`/`set_bg`/`set_color`, `set_tag(target, tag, present)`, `say(s)`/`say(s, dur)`, `scroll(lines)`, `send(target, fn [, arg])`, `teleport(id, x, y)` (jump entity `id` to a cell; `me.id` = self, `-1` = player), `push(x, y, dir)`, `shift([[x, y], …])`, `alter_gems(n)`, `alter_health(dh)`, `set_key(color, present)`, `die()`. **`log(s)` is the exception** — it is *not* an `Action`; it writes a line straight to the shared `LogSink`, so it surfaces immediately regardless of the object's queued delays (the write closure takes a `LogSink` clone). `scroll(lines)` takes a Rhai array whose elements are a string (text line) or a two-element `[choice_key, display_text]` (selectable choice). `shift` takes an array of two-int `[x, y]` arrays (a malformed entry logs an error immediately to the `LogSink`) and rotates that ring of cells via `Board::apply_shift`.
- **Queue draining:** each object's actions sit in its own `ObjQueue` until `ObjectInfo::drain(&mut Vec<BoardAction>, dt)` moves its ready run into a caller-supplied `Vec`: it first eats leading `Delay` actions with `dt` (a delay longer than `dt` is decremented and stops the drain — this caps object speed), then moves the run of ready actions across. The hook runner returns that `Vec` and `GameState` applies it immediately (per object), so scripts mutate without a `&mut GameState` borrow and each object sees the prior object's effects. `log()` output and errors bypass the queue entirely via the shared `LogSink` (`take_logs()`), so they are never paced by a `Delay`.
- **Constants in scope:** only `Registry` is pushed into each object's scope (the `me`/`state` views are hook *parameters* now). `register_global_constants` registers the `North`/`South`/`East`/`West` `Direction` values and the 16 named colors as a **global module**, visible at any call depth (including Rhai→Rhai calls and helper functions) — unlike scope constants such as `Registry`.
- `Registry` (a `Board`-handle wrapper) — `Registry.get(key)` / `set(key, value)` / `get_or(key, default)` read and write the board's `registry: HashMap<String, RegistryValue>`, which **persists across board transitions**. `set(key, ())` removes a key; unsupported value types are ignored; `get_or` only returns the stored value when it matches the default's Rhai type.
- **Sender identity uses stable ids:** the issuing `ObjectId` rides the per-call **tag**`run_*` calls `call_fn_with_options(...with_tag(id)...)` and the write fns read it via `NativeCallContext::tag()` (decoded by `source_of`; id 0, never valid, is the no-source fallback). It matches `Board::objects`' `BTreeMap` keys, so it survives object spawn/destroy/reorder.
- `GameState` (hence the `Engine`/`Scope`) is single-threaded / not `Send`; fine for kiln-tui.
**`kiln-core/src/api/`** — the Rhai **script API**: the types scripts actually touch, each implementing `script::Registerable`. Split out of `script.rs` so the host (engine/queues/dispatch) and the script-facing surface can evolve separately. (Author-facing reference: [`docs/script-api.md`](../docs/script-api.md).)
- `api::state``ScriptState { player: PlayerWithPos, board: BoardRef }` (Rhai type **`State`**), the `state` parameter of every hook. `from_game_state` snapshots the player and clones the board handle; getters `state.player` / `state.board`.
- `api::player``PlayerWithPos(Player, PlayerPos)` (Rhai type **`Player`**), a `Copy` bundle of the game-global `Player` stats and the per-board `PlayerPos` so they register as one Rhai object. Getters `gems`, `health`, `max_health`, `keys` (a `Keyring`, exposing one `bool` getter per color), `x`, `y`.
- `api::board``BoardRef = Rc<RefCell<Board>>` (Rhai type **`Board`**), a read-only handle. Getters `width`/`height`; methods `can_push(x, y, dir)`, `passable(x, y)`, `get(id)` (→ `ObjectInfo` or `()`; `id <= 0` logs an error), `named(name)` (→ `ObjectInfo` or `()`), `tagged(tag)` (→ array of `ObjectInfo`).
- `api::object_info``ObjectInfo` (a snapshot of one object: `id`, `x`, `y`, a board handle, `script_name`, and a clone of the object's `ObjQueue`), used as both the `me` parameter and the return type of the `Board` lookups (so self and other objects are the **same** type). Getters `x`/`y`/`id`/`name`/`tags`/`glyph`/`waiting`/`queue`; methods `has_tag(tag)`, `delay(secs)`, `can_push(dir)`, `blocked(dir)`. `from_id`/`from_def` build it; `drain(&mut Vec<BoardAction>, dt)` moves the underlying object's ready queued actions into the target `Vec`.
- `api::queue``ObjQueue(Rc<RefCell<VecDeque<Action>>>)` (Rhai type **`Queue`**), one object's output queue, owned by its `ObjectDef` and reachable as `me.queue`. Rhai surface: getter `length`, methods `clear()`, `delay(secs)`. Rust surface used by the host: `act`, `delay` (merges a trailing delay), `now` (promote the last-enqueued action to the front), `drain` (eat leading delays by `dt`, then move the ready actions into a `&mut Vec<BoardAction>`), `waiting` (front is a `Delay`).
**`kiln-core/src/map_file.rs`** — per-board serde shell + load/save orchestration (the grid work lives in `layer.rs`):
- `MapFile { map: MapHeader, grid: GridData, triggers: Vec<TriggerSpec>, decorations: Vec<DecorationSpec> }` — serde for one board: a `[map]` header (`name`, `width`, `height`, optional `floor: FloorSpec` and `board_script_name`; **no** `player_start` — the player is a `kind = "player"` grid char), the single `[grid]`, and the off-grid `[[triggers]]`/`[[decorations]]` lists. Does **not** include scripts (those live in `World::scripts`).
- `FloorSpec { generator?, tile?, fg?, bg? }``Floor` via `resolve` (generator → biome + pre-roll; any of tile/fg/bg → fixed glyph; empty → blank; unknown generator → blank + error). `TriggerSpec { x, y, script_name, name?, tags? }` → a non-solid glyphless scripted `ObjectDef`. `DecorationSpec { x, y, kind, tile?, fg?, bg? }` → a `Decoration` (a solid archetype is rejected).
- `TileIndex` (`pub`) — `#[serde(untagged)]` enum accepting either `Num(u32)` or `Chr(char)`; lets map files use `tile = " "` (char) or `tile = 32` (integer) interchangeably. `parse_color`/`color_to_hex` (`pub(crate)`) are shared with `layer.rs`.
- `impl TryFrom<MapFile> for Board` — the load conversion: builds the single grid via `layer::build_grid` (collecting object/portal/player placements), resolves the floor, then runs the cross-cell validations. **Best-effort/nonfatal**: only a grid-dimension mismatch returns `Err`; everything else is recorded on `Board::load_errors` (see `Board::is_valid`) — the player must appear exactly once (missing → `(0,0)`; multiple → first) and *wins* its cell; one solid per cell (a conflicting solid object is dropped); object names board-unique (a dup is cleared, via `claim_name`), portal names unique (a dup is dropped). Then `[[triggers]]` load as non-solid glyphless objects (after the grid objects) and `[[decorations]]` as off-grid non-solids. Finally it calls `Board::expand_builtin_archetypes` to turn script-backed archetype cells into their scripted objects.
- `impl From<&Board> for MapFile` — save: emits the single `[grid]` (deduping each unique `(Glyph, Archetype)` to a palette char; objects to `kind = "object"`, portals, player), **except** trigger objects (`is_trigger`: non-solid + transparent glyph + scripted + non-builtin) which go to `[[triggers]]`, and decorations to `[[decorations]]`. The floor is written back via `floor_to_spec` — a biome re-emits its `generator` name (procedural floors now round-trip), a fixed glyph its tile/fg/bg.
- `pub fn load(path: &str) -> Result<Board, …>` — reads a single-board `.toml` file. Production code uses `world::load` instead.
- `pub fn save(board: &Board, path: &Path) -> Result<(), …>` — serializes `Board` back to a single-board TOML file.
**`kiln-core/src/world.rs`** — world type and world-file loading:
- `World` — the runtime representation of a `.toml` world 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 in `Rc<RefCell<Board>>` so multiple shared refs can coexist — `GameState` clones the active board's `Rc` for its `ScriptHost`, and all boards remain in `world.boards` at all times (no board is ever "removed" when active).
- `pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>>` — reads a world `.toml` file, converts each `[boards.NAME.*]` subtable via `TryFrom<MapFile> for Board`, wraps each in `Rc::new(RefCell::new(...))`, and validates that `world.start` matches a board key.
- `pub fn deep_clone(&self) -> World` — a **fully independent** copy: rebuilds every board into a *fresh* `Rc<RefCell<Board>>` (`Board`/`Layer`/`ObjectDef` derive `Clone`) rather than sharing the `Rc`s. An explicit method, not a `Clone` impl, since shallow `Rc`-sharing would be a footgun. Used by the editor's playtest to run a game against an isolated world so play mutations never touch the boards being edited.