35 KiB
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 aconst fn(tile 64@, white on dark blue); all other glyphs come from map files.Hashis hand-implemented via packedu32representations so it stays in sync withEq.
kiln-core/src/cp437.rs — tile-index → character mapping (pub):
CP437: [char; 256]— full code-page-437 table (graphic glyphs for 0x00–0x1F, box-drawing for 0xB0–0xDF, etc.).tile_to_char(tile: u32) -> charindexes it fortile < 256; falls back tochar::from_u32then 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_constantsbuilds the RhaiBlack/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 providesbehavior(),name()(used in map files), anddefault_glyph().Cratepushable any direction (CP437 ■, char 254);HCrate/VCratepushable east/west (↔, char 29) / north/south (↕, char 18) only.ErrorBlockis a sentinel for unknown archetype names (yellow?on red).TryFrom<&str>checks theBuiltinregistry first (viaBuiltin::from_name), then the hard-coded terrain names; unrecognized names returnErr(the map loader substitutesErrorBlock).Builtin(Copy,PartialEq,Hash) — the family enum for all script-backed archetypes:Gem,Heart,Pusher,Spinner,Key. Generated by thebuiltins!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, theBUILTIN_<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 generatesenum Builtin { … }andimpl Builtin { from_name, behavior, default_glyph_for, script }. To add a new builtin: add one entry here + writesrc/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 coordinateshift, 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 readsme.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_archetypestags each expanded object withBUILTIN_<alias>(e.g."BUILTIN_pusher_east") and also uses this string as the object'sscript_namecompile-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::saveusesarchetype_from_builtin_tagto collapse the object back into its archetype keyword so maps round-trip. The script sources and behavior definitions now live inarchetype.rsvia thebuiltins!macro (viainclude_str!ofscripts/*.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 fromArchetype::behavior():solid: bool,opaque: bool,pushable: Pushable,grab: bool(the player walking onto asolid + grabthing isn't blocked — it moves onto it and the thing'sgrab()hook fires; this is the only grab trigger;GemandHeartboth set the flag).ObjectId = u32— stable identifier for board objects.Solid— the single solid occupant of a cell, returned byBoard::solid_at:Player,Cell(Archetype), orObject(ObjectId).PlayerPos { x: i32, y: i32 }— the player's current position on a board (held asBoard::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 — seeapi::queue),solid: bool(defaulttrue),opaque: bool(defaulttrue),pushable: bool(defaultfalse),grab: bool(defaultfalse; set when a grab archetype like a gem is expanded — walking onto it firesgrab()),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 setsscript_nameto a syntheticBUILTIN_*compile-key, sobuiltin_scriptsupplies the source whilescript_nameis the key; not serialized — see [builtin_scripts]),tags: HashSet<String>,name: Option<String>. The optionalnameis validated for board-wide uniqueness at load time; the first claimant keeps the name and any later duplicate has its name cleared toNone(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 whoseglyph.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 (precedencecontent→fill→sparse; none ⇒ all spaces):content(a multi-line grid string),fill(one char filling the whole grid — handy for a uniform floor layer), orsparse(aVec<SparseCell>of{ x, y, ch }over an otherwise all-spaces grid — handy for a layer of just a few objects). Onlycontentcan mismatch the board dims (hard error);fill/sparseare always exactly sized (a badfill/chor out-of-boundssparsecell is a nonfatal error).PaletteEntryis a single flat struct with akind: Stringdiscriminator 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, becausekindis 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 (viaresolve_entry), validates the grid dims (the only hardErr), and walks the grid building theLayerplus aVec<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 seededStdRand.resolve_entrymapskind→Resolved:empty→ transparent cell;floor→ a generator (per-cell roll) or a fixed visual-onlyEmptyglyph;object/portal/player→ a placement; any other string →Archetype::try_from(Ok→ terrain cell;Err→ visibleErrorBlock+ logged error). Aportalmissingname/target_map/target_entryis 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 onBoard— they live in [World::scripts]. The visual floor is no longer a separate field: it is justEmptycells 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 (returnsGlyph::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 iftile != 0). Nothing anywhere → the canonical blackEmptyglyph. 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 viasolid_cell_layer).is_passable(x, y)— convenience inverse ofsolid_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)headingdirbumps: the solid object in the target cell, or the one at the end of a chain of pushed crates (walked through indir). Open space, the player, or a wall yieldNone. This is what lets a plain crate — not just an object or the player — trigger abump. Used byGameState::try_move/step_object; the caller supplies the came-from direction asdir.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, unlikecan_push). The player counts as a blocker for this check. A Rust-side companion read toapply_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 viasolid_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 solidgrabobject on the cell (the player-walks-onto-it case).GameState::try_moveuses it to firegrab()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_objectdeletes the object and itsObjQueuefrom theobjectsmap; a liveScriptHostkeeps an unusedScopefor 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 scriptshift()fn. A cell is immobile if its solid is unpushable, or is anHCrate/VCrateasked 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 isplaced at its target (a displaced object is despawned viaremove_object). Any out-of-bounds coordinate rejects the whole shift (returned as aLogLine). 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-Emptycell) or else the top layer;Emptyerases — removes every object plus the terrain cell (→ transparentEmpty). Note it writes terrain even for script-backed archetypes (pushers/spinners), so the editor stamps an inert cell — seeexpand_builtin_archetypes.expand_builtin_archetypes()— the single expansion point: replaces everyArchetype::Builtin(b, alias)terrain cell with the scripted object it expands to. For each such cell: vacates the terrain slot (→ transparentEmpty), callsb.script()for the embedded source, usesbuiltin_tag(arch)as both theBUILTIN_<alias>tag and thescript_namecompile-cache key, and copies the cell's glyph (so palette overrides survive). The object'spushable/grabflags come fromb.behavior(). Idempotent (vacated cells becomeArchetype::Empty, so a second pass finds nothing). Called byTryFrom<MapFile> for Boardafter cross-layer validation (disk loads) and again by the editor'splaytest()on theWorld::deep_clone(so editor-stamped machines, whichplace_archetypewrites 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 byGameState::player(separate from the per-boardPlayerPosposition inutils.rs).Defaultstartshealth = max_health = 5,gems = 0, no keys.alter_gems(delta) -> booladds togemsbut refuses (returnsfalse, no change) if it would go negative.alter_health(delta)adds tohealthclamped to[0, max_health]. ACopysnapshot is handed to each script hook and exposed to Rhai (read-only) as thePlayerconstant.
kiln-core/src/keys.rs — key inventory and colors (pub):
KeyType(Red/Orange/Yellow/Green/Blue/Cyan/Purple/White) — the eight key colors.glyph() -> Glyphreturns the key tile (12,♀) in that color's fg — the single source of truth for key colors, used byKeyring::colorsand by kiln-tui's editor menu. (Moved here fromgame.rs;archetype.rs'sKeybuiltin aliases must match these glyphs — see itskey_aliases_have_distinct_fg_colorstest.)Keyring { red, orange, yellow, green, blue, cyan, purple, white: bool }— the player's key inventory (one bool per color), held byPlayer::keys.set_by_name(name, value) -> boolsets a slot by color name (returnsfalsefor 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'ssay(s)call.remainingcounts down inGameState::tick; when it reaches zero the bubble is removed. At most one bubble per object (a newsay()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'sscroll(lines)call. Lives onGameState::active_scroll: Option<Scroll>; front-ends pause ticks while it'sSomeand close it viaclose_scroll(choice).ScrollLineis re-exported fromaction.rsthroughgame.rsso front-ends import both fromkiln_core::game.GameState— ownsworld: World(all boards asRc<RefCell<Board>>+ world scripts) andcurrent_board_name: String(key of the active board).pub log: Vec<LogLine>,scripts: ScriptHost,pub speech_bubbles: Vec<SpeechBubble>,pub active_scroll: Option<Scroll>, andpub 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::playerholds only the per-boardPlayerPos).player.gemsis changed at runtime by thealter_gems(n)script fn (e.g. grabbing a gem);player.healthis changed byalter_health(dh)(clamped to[0, max_health], e.g. grabbing a heart);player.keysis changed byset_key(color, present). Each script-hook call is handed aScriptStatebundle — aCopysnapshot ofplayerplus a shared handle to the active board — passed to the hook as itsstateparameter (Rhai typeState, read viastate.player/state.board; seeapi::state). Front-ends reach the active board throughboard() -> Ref<Board>/board_mut() -> RefMut<Board>(both look upworld.boards[current_board_name]).current_board_name() -> &strreturns the active key.from_world(world: World) -> Selfis the primary constructor: clones the start board'sRc<RefCell<Board>>for theScriptHost, compiles scripts fromworld.scripts.new(board)andwith_scripts(board, scripts)are#[cfg(test)]-only sugar that build a minimal single-boardWorld.try_move(dir)moves the player (pushing if possible) and firesbump(dir_from)on the solid object it presses into — directly or at the end of a chain of crates it shoves (seeBoard::bump_target), wheredir_fromis the side the bump came from; walking onto agrabthing (viaBoard::grab_object_at) instead moves onto it and firesgrab()+ an immediateresolve()so itsdie()/alter_gems()/alter_health()apply before the call returns (no player+object overlap).try_moveis 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 objectinit()hooks once at startup.tick(dt)advances cooldowns, expires speech bubbles, and runstick(dt)hooks every frame. Both end by callingresolve(), which drains the board queue and applies eachAction(Log→log,SetTile/SetColor→ source glyph,Move(dir)→step_object,Say→speech_bubbles,Scroll→active_scroll,AddGems(n)→player.gems,AlterHealth(dh)→player.healthclamped to[0, max_health],SetKey→player.keys,Shift→apply_shift,Push/Teleport→ board moves,Send→run_send,Die→remove_object(source)). Resolution is two-phase: mutate the board collecting(bumped, dir_from)pairs (the bumped object and the direction the bump came from), drop the borrow, then firerun_bumpfor each pair.drain_errors()moves script errors intolog.close_scroll(choice)clearsactive_scrolland optionally firesrun_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 aScrollaction:Text(String)(plain, word-wrapped) orChoice { choice, display }(selectable;choiceis sent back to the source object when the player picks it).SendArg— the optional argument carried by aSendaction /send()call:None/Int/Float/String, withFrom<Dynamic>/Into<Dynamic>conversions.Action(pub(crate)) — deferred mutation emitted by a script and applied byGameStateafter 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 byObjQueue::drainto 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)(changeGameState::player.gems, clamped at 0; zero cost),AlterHealth(i64)(changeGameState::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 }— anActionpromoted onto the board queue, tagged with theObjectIdthat issued it.
kiln-core/src/floor.rs — procedural floor generators:
- A "floor" is just a non-solid, visible
Emptycell on a layer (a palette entry withkind = "floor"). This module only owns the generators; the actual per-cell placement happens inlayer.rsduring 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. ,). Usestinyrand(already a workspace dep, WASM-safe).FLOOR_SEED(pub(crate)) seeds oneStdRandper 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> }andLogLine { spans: Vec<LogSpan> }— a UI-agnostic styled message (colors are coreRgba8, not a front-end type).LogLine::raw(), a chainablepush(),append(), andLogLine::error()(a red-on-black single-span constructor used for nonfatal load errors) build messages; each front-end converts aLogLineto 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.
Registerabletrait —fn register(engine, error_sink): a type's hook for installing itself (Rhai type name + getters/methods) on theEngine. Implemented by everyapitype plusGlyph/Keyring.ScriptHost— owns the RhaiEngine, the compiled scripts (HashMap<String, CompiledScript>), one persistentScopeper scripted object (HashMap<ObjectId, Scope>), the shared board queue, and the error sink. Built withScriptHost::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'sRegistry), the second is the world-level script pool. Each script is compiled once perscript_key(the object'sscript_name— a world-pool name, or a syntheticBUILTIN_*name assigned byexpand_builtin_archetypesso identical built-ins share one AST); the source is the pool entry for that key, or the object's embeddedbuiltin_script.CompiledScriptrecords which hooks the AST defines (has_init/has_tick/has_bump/has_grab). Per-object output queues no longer live here — eachObjectDefowns itsqueue: ObjQueue. Reports compile/unknown-script failures onto the error sink; runs nothing.- Lifecycle hooks, each taking
me(anObjectInfo) andstate(aScriptState) before any hook-specific arg:init(me, state),tick(me, state, dt),bump(me, dir)(diris theDirectionthe bump came from, fired when any solid — the player, another object, or a pushed crate — presses into this object), andgrab(me, state)(fired when the player walks onto agrabthing — the only grab trigger). All optional — detected viaAST::iter_functions()by name and arity (bump is arity 2). Driven byrun_init(state)/run_tick(state, dt)/run_bump(id, dir)/run_grab(state, id), all funneling throughrun_hook_on_one: it builds a freshObjectInfofor 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 notick. 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 thesend()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 isDynamic::UNIT. Errors if no function of that name exists.- Reads are methods/getters on the
apitypes handed to the hook —state.player.*,state.board.*,me.*(see theapi/section). There are no read free functions anymore. - Write API (
register_write_api) — free functions that enqueue anActiononto the issuing object'squeue(resolved from the per-call tag):move(dir)(enqueuesMovethen aMOVE_COSTdelay),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(id, x, y)(jump entityidto 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().scroll(lines)takes a Rhai array whose elements are a string (text line) or a two-element[choice_key, display_text](selectable choice).shifttakes an array of two-int[x, y]arrays (a malformed entry logs an error) and rotates that ring of cells viaBoard::apply_shift. - Queue draining (two-tier): each object's actions sit in its own
ObjQueueuntilObjectInfo::drain(board_queue, dt)promotes them onto the shared board queue (Vec<BoardAction>): it first eats leadingDelayactions withdt(a delay longer thandtis decremented and stops the drain — this caps object speed), then moves the run of ready actions across.GameStatetakes the board queue withtake_board_queue()and applies it after the batch, so scripts mutate without a&mut GameStateborrow. Errors bypass the queues via the error sink (take_errors()). - Constants in scope: only
Registryis pushed into each object's scope (theme/stateviews are hook parameters now).register_global_constantsregisters theNorth/South/East/WestDirectionvalues 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 asRegistry. Registry(aBoard-handle wrapper) —Registry.get(key)/set(key, value)/get_or(key, default)read and write the board'sregistry: HashMap<String, RegistryValue>, which persists across board transitions.set(key, ())removes a key; unsupported value types are ignored;get_oronly returns the stored value when it matches the default's Rhai type.- Sender identity uses stable ids: the issuing
ObjectIdrides the per-call tag —run_*callscall_fn_with_options(...with_tag(id)...)and the write fns read it viaNativeCallContext::tag()(decoded bysource_of; id 0, never valid, is the no-source fallback). It matchesBoard::objects'BTreeMapkeys, so it survives object spawn/destroy/reorder. GameState(hence theEngine/Scope) is single-threaded / notSend; 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::state—ScriptState { player: PlayerWithPos, board: BoardRef }(Rhai typeState), thestateparameter of every hook.from_game_statesnapshots the player and clones the board handle; gettersstate.player/state.board.api::player—PlayerWithPos(Player, PlayerPos)(Rhai typePlayer), aCopybundle of the game-globalPlayerstats and the per-boardPlayerPosso they register as one Rhai object. Gettersgems,health,max_health,keys(aKeyring, exposing oneboolgetter per color),x,y.api::board—BoardRef = Rc<RefCell<Board>>(Rhai typeBoard), a read-only handle. Getterswidth/height; methodscan_push(x, y, dir),passable(x, y),get(id)(→ObjectInfoor();id <= 0logs an error),named(name)(→ObjectInfoor()),tagged(tag)(→ array ofObjectInfo).api::object_info—ObjectInfo(a snapshot of one object:id,x,y, a board handle,script_name, and a clone of the object'sObjQueue), used as both themeparameter and the return type of theBoardlookups (so self and other objects are the same type). Gettersx/y/id/name/tags/glyph/waiting/queue; methodshas_tag(tag),delay(secs),can_push(dir),blocked(dir).from_id/from_defbuild it;drain(target, dt)promotes the underlying object's queued actions onto the board queue.api::queue—ObjQueue(Rc<RefCell<VecDeque<Action>>>)(Rhai typeQueue), one object's output queue, owned by itsObjectDefand reachable asme.queue. Rhai surface: getterlength, methodsclear(),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 bydt, then promote ready actions),waiting(front is aDelay).
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, optionalboard_script_name; noplayer_start— the player is akind = "player"palette char) plus the[[layers]]stack. Does not include scripts (those live inWorld::scripts).TileIndex(pub) —#[serde(untagged)]enum accepting eitherNum(u32)orChr(char); lets map files usetile = " "(char) ortile = 32(integer) interchangeably.parse_color/color_to_hex(pub(crate)) are shared withlayer.rs.impl TryFrom<MapFile> for Board— the load conversion: builds each layer vialayer::build_layer(collecting object/portal/player placements with their layerz), then runs the cross-layer validations. Best-effort/nonfatal: only a layer grid-dimension mismatch returnsErr; everything else is recorded onBoard::load_errors(seeBoard::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 callsBoard::expand_builtin_archetypesto 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 byzand the player written onto the top layer. Generators baked to literal glyphs at load are saved as fixedfloorglyphs (the generator name is not recovered).pub fn load(path: &str) -> Result<Board, …>— reads a single-board.tomlfile. Production code usesworld::loadinstead.pub fn save(board: &Board, path: &Path) -> Result<(), …>— serializesBoardback to a single-board TOML file.
kiln-core/src/world.rs — world type and world-file loading:
World— the runtime representation of a.tomlworld 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 inRc<RefCell<Board>>so multiple shared refs can coexist —GameStateclones the active board'sRcfor itsScriptHost, and all boards remain inworld.boardsat all times (no board is ever "removed" when active).pub fn load(path: &str) -> Result<World, Box<dyn std::error::Error>>— reads a world.tomlfile, converts each[boards.NAME.*]subtable viaTryFrom<MapFile> for Board, wraps each inRc::new(RefCell::new(...)), and validates thatworld.startmatches a board key.pub fn deep_clone(&self) -> World— a fully independent copy: rebuilds every board into a freshRc<RefCell<Board>>(Board/Layer/ObjectDefderiveClone) rather than sharing theRcs. An explicit method, not aCloneimpl, since shallowRc-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.