Files
kiln/kiln-core/CLAUDE.md
T
2026-07-05 23:41:19 -05:00

34 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, 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). 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, Cell(Archetype), or Object(ObjectId).
  • 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 { 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: id: ObjectId (its stable id, assigned when the object is added to a board), x, y, z: usize (layer index, drives draw order), glyph: Glyph, queue: ObjQueue (its private output queue of pending [Action]s, drained onto the board queue between script calls — 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>. 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 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 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/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.)
  • 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 (the cell that would feed an immobile cell is also held), so a blocked run stays put while the free tail still rotates. Movable cells are cleared, then each mover is placed at its target (a displaced object is despawned via remove_object). Any out-of-bounds coordinate rejects the whole shift (returned as a LogLine). 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 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/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 add_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(-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()/alter_health() 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/SetColor → source glyph, Move(dir)step_object, Sayspeech_bubbles, Scrollactive_scroll, AddGems(n)player.gems, AlterHealth(dh)player.health clamped to [0, max_health], SetKeyplayer.keys, Shiftapply_shift, Push/Teleport → board moves, Sendrun_send, 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 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 and applied by GameState after promotion onto the board queue: Move(Direction), SetTile(u32), Log(LogLine), SetTag { target, tag, present }, Say(String, f64) (bubble text + duration), Delay(f64) (never reaches the board queue — 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 promoted onto the board queue, tagged with the ObjectId that issued it.

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 host (the script-facing types live in kiln-core/src/api/, below). The script-author's reference is docs/script-api.md.

  • Registerable trait — fn register(engine, error_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>), the shared board queue, and the error sink. 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 error sink; 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, state, id) (the bumper's ObjectId, or -1 for the player), 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 (2/3/3/2). Driven by run_init(state) / run_tick(state, dt) / run_bump(state, id, bumper) / run_grab(state, id), all funneling through run_hook_on_one: it builds a fresh ObjectInfo for the object, 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 error sink (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 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), log(s), say(s)/say(s, dur), scroll(lines), send(target, fn [, arg]), teleport(x, y), push(x, y, dir), shift([[x, y], …]), add_gems(n), alter_health(dh), set_key(color, present), die(). 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) and rotates that ring of cells via Board::apply_shift.
  • Queue draining (two-tier): each object's actions sit in its own ObjQueue until ObjectInfo::drain(board_queue, dt) promotes them onto the shared board queue (Vec<BoardAction>): 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. GameState takes the board queue with take_board_queue() and applies it after the batch, so scripts mutate without a &mut GameState borrow. Errors bypass the queues via the error sink (take_errors()).
  • 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 tagrun_* 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.)

  • api::stateScriptState { 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::playerPlayerWithPos(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::boardBoardRef = 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_infoObjectInfo (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(target, dt) promotes the underlying object's queued actions onto the board queue.
  • api::queueObjQueue(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 promote ready actions), waiting (front is a Delay).

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.