Files
kiln/kiln-core/CLAUDE.md
T
2026-06-23 01:08:01 -05:00

29 KiB
Raw Blame History

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, Pusher, Spinner. 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), 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). 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, and only Gem sets the flag).
  • ObjectId = u32 — stable identifier for board objects.
  • Solid — the single solid occupant of a cell, returned by Board::solid_at: Player, Cell(Archetype), or Object(ObjectId).
  • Player { x: i32, y: i32 } — current player position.
  • PortalDef { x, y, target_map, target_entry } — parsed from map files, not yet runtime-wired.

kiln-core/src/object_def.rs — scripted objects (pub(crate)):

  • ObjectDef — a scripted tile: x, y, z: usize (layer index, drives draw order), glyph: Glyph, 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>. 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::new(x, y) constructs with defaults (z = 0). ObjectDef::default_glyph() returns tile 63 (?) yellow on black.

kiln-core/src/layer.rs — palette layers (the map-file/draw-stack unit):

  • Layer { cells: Vec<(Glyph, Archetype)> } (pub(crate)) — one row-major draw layer. A cell whose glyph.tile == 0 (Glyph::transparent()) draws nothing, so the layer beneath shows through; solidity comes from the archetype, independent of transparency.
  • LayerData { content, fill, sparse, palette: HashMap<String, PaletteEntry> } — serde for one [[layers]] entry. The grid comes from exactly one of three optional fields (precedence contentfillsparse; none ⇒ all spaces): content (a multi-line grid string), fill (one char filling the whole grid — handy for a uniform floor layer), or sparse (a Vec<SparseCell> of { x, y, ch } over an otherwise all-spaces grid — handy for a layer of just a few objects). 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, generator, solid, opaque, pushable, script_name, tags, name, target_map, target_entry); only the fields relevant to the kind are read. (One flat struct, not an enum, because kind is open-ended — any archetype name or a meta-kind.)
  • build_layer(data, w, h, &mut StdRand, &mut Vec<LogLine>) -> Result<(Layer, Vec<Placement>), String> — resolves the palette (via resolve_entry), validates the grid dims (the only hard Err), and walks the grid building the Layer plus a Vec<Placement> (Object(ObjectTemplate, x, y) / Portal(PortalTemplate, x, y) / Player(x, y)) for the map loader to resolve across layers. Procedural floors roll a fresh glyph per cell from the shared seeded StdRand.
  • resolve_entry maps kindResolved: empty → transparent cell; floor → a generator (per-cell roll) or a fixed visual-only Empty glyph; 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, layers: Vec<Layer> (bottom→top draw stack; pub(crate)), player: Player, objects: BTreeMap<ObjectId, ObjectDef>, next_object_id: ObjectId, portals: Vec<PortalDef>, board_script_name: Option<String> (name of a board-level script in the world script pool, if any), load_errors: Vec<LogLine> (pub(crate)), registry: HashMap<String, RegistryValue>. Scripts are not stored on Board — they live in [World::scripts]. The visual floor is no longer a separate field: it is just Empty cells with a visible glyph on a lower layer.
  • layer_count() -> usize; get(z, x, y) -> &(Glyph, Archetype) / get_mut(z, x, y) — per-layer cell access (panics OOB).
  • glyph_at(x, y) -> Glyph — the glyph a renderer should display at (x, y). The player draws on top (returns Glyph::player() at its cell); otherwise layers are walked top-down and the first thing that draws wins: a solid object on that layer (always) or a visible non-solid object, else a portal on that layer, else the layer's terrain cell (a solid always draws; a non-solid only if tile != 0). Nothing anywhere → 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 objects, then a solid terrain archetype on any layer via solid_cell_layer).
  • 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?
  • 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 right gate for a simultaneous shift/rotation via apply_swap. The player is always a blocker (a shift can't relocate it and apply_swap refuses to overwrite it).
  • push(x, y, dir) — mutating: shoves the chain one step, leaving a transparent cell behind (so a lower floor layer is revealed). A pushed solid moves within its own layer (found via solid_cell_layer).
  • 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/swapped into the player is treated as an ordinary solid (no special-case). (remove_object only edits the objects map; a live ScriptHost keeps a stale ObjectRuntime whose later host-fn calls resolve to a missing id and no-op — benign.)
  • apply_swap(pairs: &[(i32,i32,i32,i32)]) -> Vec<LogLine> — applies a batch of one-way solid moves simultaneously (reads every source's solid occupant — player/object/terrain — into a private SolidSnapshot before writing any destination, so cycles and two-cell swaps resolve). A source with no solid moves an "empty", removing the destination's solid (terrain cleared; a displaced object despawned via remove_object). The player is never destroyed — a write that would overwrite it without relocating it is skipped + logged (a grab object is no exception: it is refused like any other solid). Because a refused solid stays at its source cell, which another entry may also target, a final overlap sweep over the swapped cells keeps one solid per cell and deletes the rest (never the player), logging an error per deletion. Out-of-bounds entries are skipped + logged. Backs the script swap() fn.
  • place_archetype(x, y, arch, glyph) — editor stamp primitive (used by kiln-tui's drawing tools). Keyed only on the archetype, leaving any floor untouched: a non-Empty (solid) arch removes a solid object in the cell then writes (glyph, arch) into the existing terrain layer (terrain_layer_at, the single non-Empty cell) or else the top layer; Empty erases — removes every object plus the terrain 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) terrain cell with the scripted object it expands to. For each such cell: vacates the terrain slot (→ 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-layer validation (disk loads) and again by the editor's playtest() on the World::deep_clone (so editor-stamped machines, which place_archetype writes as inert terrain, also run). Builtin objects get ids after hand-placed ones (layer-then-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/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>, pub player_health: u32 (starts 5) and pub player_gems: u32 (starts 0) — game-global player stats that persist across board transitions. player_gems is changed at runtime by the add_gems(n) script fn (e.g. grabbing a gem); player_health is still display-only. 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(-1) on any solid object walked into; walking onto a grab thing (via Board::grab_object_at) instead moves onto it and fires grab() + an immediate resolve() so its die()/add_gems() apply 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) advances cooldowns, expires speech bubbles, and runs tick(dt) hooks every frame. Both end by calling resolve(), which drains the board queue and applies each Action (Loglog, SetTile → source glyph, Move(dir)step_object, Sayspeech_bubbles, Scrollactive_scroll, AddGems(n)player_gems, Dieremove_object(source)). Resolution is two-phase: mutate the board collecting (bumped, bumper) pairs, drop the borrow, then fire run_bump for each pair. drain_errors() moves script 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 Rhai-facing conversion:

  • 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).
  • Action (pub(crate)) — deferred mutation emitted by a script and applied by GameState after promotion onto the board queue: Move(Direction), SetTile(u32), Log(LogLine), SetTag { target, tag, present }, Say(String), Delay(f64) (never reaches the board queue — consumed by ScriptHost::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), Swap(Vec<(i32,i32,i32,i32)>) (batch of simultaneous one-way solid moves; zero time cost), AddGems(i64) (change GameState::player_gems, clamped at 0; zero cost), Die (remove the source object; zero cost).
  • action_to_map(action) -> rhai::Map — converts an Action to a Rhai map for Queue.peek() / Queue.pop(). The map always has a "type" key; other keys carry the payload. Scroll/Swap emit type only (their payloads are not inspectable via the map API). Log is flattened to its first span's text.

kiln-core/src/floor.rs — procedural floor generators:

  • A "floor" is just a non-solid, visible Empty cell on a layer (a palette entry with kind = "floor"). This module only owns the generators; the actual per-cell placement happens in layer.rs during load.
  • FloorGenerator (Grass/Dirt/Stone) — procedural textures differing only in color scheme and texture-char probability. from_name(&str) parses the map-file name; 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 StdRandper board build (inmap_file`), threaded through every generator call, so floors are deterministic/testable and depend only on map content.

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 runtime:

  • ScriptHost — owns the Rhai Engine, the compiled scripts referenced by a board's objects, a per-object persistent Scope + output queue + ready timer, and the shared board queue. Built with ScriptHost::new(&Rc<RefCell<Board>>, &HashMap<String, String>): the first arg is a shared ref to the active board (used by read-API closures), the second is the world-level script pool. Each object resolves to a (key, source) compiled once per key: the key is the object's script_name (a world-pool name for a named script, or a synthetic BUILTIN_* name assigned by expand_builtin_archetypes so identical built-ins, e.g. all pushers, share one AST); the source is the pool entry for that name, or the object's embedded builtin_script when present. Reports compile/unknown-script failures onto the error sink; runs nothing.
  • Lifecycle hooks per object: init() (zero-arg), tick(dt) (elapsed seconds as f64), bump(id) (the bumper's ObjectId, or -1 for the player), and grab() (zero-arg; fired when the player walks onto a grab thing — the only grab trigger), all optional — detected via AST::iter_functions() by name and arity. Driven by run_init() / run_tick(dt) / run_bump(object_id, bumper) / run_grab(object_id); runtime errors go to the error sink (drained to the log), not fatal.
  • Reads (direct): read getters (player_x, player_y, width, height) are registered on Rc<RefCell<Board>> (the BoardRef alias, exposed to Rhai under the type name Board) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constant Board. blocked(dir) -> bool is also a read fn: true if the caller's target cell is off-board, already solid, or the destination of a pending move on the board queue (an object never sees its own move, which is pumped only after its script returns). can_push(x, y, dir) -> bool is a read fn mirroring Board::can_push: true if (x, y) holds a pushable whose chain can be shoved in dir (false off-board). can_shift(x, y, dir) -> bool mirrors Board::can_shift: like can_push but only checks the single cell ahead (pushable source + an empty-or-pushable next cell), the right gate for a simultaneous shift/rotation via swap. passable(x, y) -> bool mirrors Board::is_passable: true if (x, y) is on-board and holds no solid (off-board → false), letting a script tell a hole apart from a blocked solid (which can_shift/can_push alone cannot). has_tag(s) -> bool and get_tags() -> Array read the calling object's tag set; objects_with_tag(s) -> Array returns all object ids carrying that tag. my_name() -> String returns the calling object's name (or "" if unnamed); object_id_for_name(s) -> i64 looks up an object by name and returns its id, or 0 if not found. Each getter briefly borrows the shared Board.
  • Actions & rate limiting (two-tier queues): host fns move(dir), set_tile(n), log(s), say(s), scroll(lines), push(x, y, dir), swap(pairs), add_gems(n) (adjust the player's gem count), die() (remove the calling object) append an Action (Move/SetTile/Log/Say/Scroll/Push/Swap; MOVE_COST = 0.25 s for Move, else 0 — push/swap add no delay) to the issuing object's output queue (routed by the per-call tag via a HashMap<usize, ObjQueue>). scroll(lines) takes a Rhai array where each element is a string (text line) or a two-element array [choice_key, display_text] (selectable choice). push(x, y, dir) shoves the pushable chain at arbitrary coords (x, y). swap(pairs) takes an array of four-int [src_x, src_y, dst_x, dst_y] arrays (a malformed entry is skipped + logged) and moves all the named solids simultaneously (see Board::apply_swap). ScriptHost::pump(i) promotes ready actions onto the shared board queue (Vec<BoardAction { source, action }>): the leading run of zero-cost actions plus at most one timed action, which arms that object's ready timer and ends the pump. While a ready timer is > 0 nothing is pulled (this caps object speed); advance_timers(dt) counts them down each frame. GameState drains the board queue with take_board_queue() and applies it after the batch — so scripts mutate without a &mut GameState borrow. Errors (compile/runtime) bypass the queues via the error sink (take_errors()).
  • The Queue object (a handle to the calling object's output queue) is pushed into each scope; scripts call Queue.length(), Queue.clear(), Queue.peek() (front action as a Rhai map, or () if empty), and Queue.pop() (same, removes the front). Safe to mutate from script because the host only touches output queues between calls (during pump).
  • Sender identity: source (which object issued an action) rides the per-call tagrun calls call_fn_with_options(...with_tag(object_id)...) and the host fns read it via NativeCallContext::tag() (decoded back to an ObjectId by source_of). 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.
  • GameState (hence the Engine/Scope) is single-threaded / not Send; fine for kiln-tui.
  • Sender identity uses stable ids: BoardAction.source, ObjectRuntime.object_id, and the QueueMap keys are all the object's ObjectId (matching Board::objects' BTreeMap keys), so they survive object spawn/destroy/reorder. The per-call tag carries the id as an i64 (id 0 — never valid — is the no-source fallback). ScriptHost::objects is still a Vec<ObjectRuntime> built by iterating the board map, so it stays in ascending-id order.

kiln-core/src/map_file.rs — per-board serde shell + load/save orchestration (the bulk of the per-layer work lives in layer.rs):

  • MapFile { map: MapHeader, layers: Vec<LayerData> } — serde for one board: a [map] header (name, width, height, optional board_script_name; no player_start — the player is a kind = "player" palette char) plus the [[layers]] stack. Does not include scripts (those live in World::scripts).
  • 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 each layer via layer::build_layer (collecting object/portal/player placements with their layer z), then runs the cross-layer validations. Best-effort/nonfatal: only a layer 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 across all layers (a stacked solid is dropped); object names board-unique (a dup is cleared), portal names unique (a dup is dropped). Object ids are assigned in layer-then-reading order. Finally it calls Board::expand_builtin_archetypes to turn script-backed archetype cells (pushers/spinners) into their scripted objects (these get ids after the hand-placed objects).
  • impl From<&Board> for MapFile — save: emits one [[layers]] per board layer (deduping each unique (Glyph, Archetype) to a palette char), with objects/portals grouped by z and the player written onto the top layer. Generators baked to literal glyphs at load are saved as fixed floor glyphs (the generator name is not recovered).
  • 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 Rcs. 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.