30 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,Pusher(Direction),Spinner(SpinDirection),Gem,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.Pusher(dir)(pusher_north|south|east|west) is a map-file keyword only: at load it expands into a scripted object (see [builtin_scripts]), never a terrain cell.Spinner(SpinDirection)(SpinDirection::{Clockwise,CounterClockwise}; keywordsspinner_cw|spinner_ccw) is the same kind of script-backed keyword: it expands into aspinner.rhaiobject that rotates its 8 neighbours each 0.5 s and animates its glyph (default glyph is the first frame,/CW /\CCW, gray on black).Gem(keywordgem) is also a script-backed keyword: solid + non-opaque + pushable + grab (the only archetype withgrab: true), it expands into agem.rhaiobject (default glyph blue ♦, CP437 tile 4) whosegrab()hook adds a gem to the player anddie()s.ErrorBlockis a sentinel for unknown archetype names (yellow?on red).TryFrom<&str>parses by name; unrecognized names returnErr(the map loader substitutesErrorBlock).
kiln-core/src/builtin_scripts.rs — archetypes that are really scripted objects (pub(crate)):
archetype_script(arch) -> Option<&'static str>— the dispatch (one arm per script-backed archetype) returning the embedded Rhai source (include_str!); currentlyPusher(_) -> scripts/pusher.rhai,Spinner(_) -> scripts/spinner.rhai, andGem -> scripts/gem.rhai(justfn grab() { add_gems(1); die(); }). Such an archetype loads as a plain terrain cell (layer::resolve_entry);Board::expand_builtin_archetypes(called fromTryFrom<MapFile>at load and again before a playtest) is the single place that turns it into anObjectDefcarrying that source inbuiltin_scriptplus aBUILTIN_<archetype>tag. The spinner reads itsBUILTIN_spinner_cw/_ccwtag for spin direction (defaulting clockwise) and stores per-instance animation frame state in the boardRegistry.archetype_script_key(arch) -> Option<&'static str>— the syntheticBUILTIN_*compile-key for a script-backed archetype ("BUILTIN_pusher"/"BUILTIN_spinner"/"BUILTIN_gem").expand_builtin_archetypesstores it as the expanded object'sscript_nameso all instances share one compiled AST (the source comes fromarchetype_script, keyed by this name). Distinct frombuiltin_tag, which is per-direction (BUILTIN_pusher_east) and drives save round-tripping.BUILTIN_TAG_PREFIX("BUILTIN_"),builtin_tag(arch) -> String,archetype_from_builtin_tag(tag) -> Option<Archetype>— the tag convention. The script reads its tag for per-instance params (the pusher reads it for its direction, sinceMeisn't visible inside Rhai helper functions);map_file::saveuses it to collapse the object back into its archetype keyword so maps round-trip.scripts/pusher.rhaiself-propels viamove(dir)(which already shoves chains viastep_object/push), pacing itself withdelayto the old ~0.5 s cadence.
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(walking into asolid + grabthing isn't blocked — the player moves onto it and itsgrab()hook fires; onlyGemsets it).ObjectId = u32— stable identifier for board objects.Solid— the single solid occupant of a cell, returned byBoard::solid_at:Player,Cell(Archetype), orObject(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(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?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 right gate for a simultaneous shift/rotation viaapply_swap. Shifting onto the player counts only when the source is agrabthing (it gets grabbed, the player isn't moved); any other solid treats the player as a blocker.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) andpushed_grab_into_player(x, y, dir) -> Option<ObjectId>(walk the pushable chain from(x,y); if a chain cell is agrabobject whose forward neighbour is the player, return it — the grab-thing-shoved-into-the-player case).GameStateuses both to decide when to firegrab()instead of pushing/sliding the player. (remove_objectonly edits theobjectsmap; a liveScriptHostkeeps a staleObjectRuntimewhose later host-fn calls resolve to a missing id and no-op — benign.)apply_swap(pairs: &[(i32,i32,i32,i32)]) -> (Vec<LogLine>, Vec<ObjectId>)— applies a batch of one-way solid moves simultaneously (reads every source's solid occupant — player/object/terrain — into a privateSolidSnapshotbefore 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 viaremove_object). The player is never destroyed — a write that would overwrite it without relocating it is skipped + logged. A grab object whose destination is the player isn't written onto it — it is left in place and its id is returned (the second tuple element) soGameStatefires itsgrab()hook (collected into the samegrabslist as the push path). Because a grabbed object stays put (and itsgrab()may not despawn it), a final overlap sweep over the swapped cells keeps one solid per cell — preferring a grabbed object so itsgrab()can still fire — and deletes the rest (never the player), logging an error per deletion. Out-of-bounds entries are skipped + logged. Backs the scriptswap()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-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 every script-backed terrain cell (e.g.Spinner/Pusher/Gem, those with anarchetype_script) with the scripted object it expands to (embedded script +BUILTIN_*tag + behavior, copying the cell's glyph so palette overrides survive). The object'spushable/grabflags are taken from the archetype'sBehavior(so a gem becomes pushable + grabbable; pushers/spinners stay unpushable). Idempotent (cells already loaded as objects are skipped). Called byTryFrom<MapFile> for Boardafter cross-layer validation (so disk loads get working machines) and again by the editor'splaytest()on theWorld::deep_clone(so editor-stamped machines, whichplace_archetypewrites as inert terrain, also run — the playtest skips the reload path). Because it runs after explicit-object placement, builtin objects get ids after hand-placed ones (still 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'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>,pub player_health: u32(starts 5) andpub player_gems: u32(starts 0) — game-global player stats that persist across board transitions.player_gemsis changed at runtime by theadd_gems(n)script fn (e.g. grabbing a gem);player_healthis still display-only. 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(-1)on any solid object walked into; walking onto agrabthing (viaBoard::grab_object_at) instead moves onto it and firesgrab()+ an immediateresolve()so itsdie()/add_gems()apply before the call returns (no player+object overlap).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→ source glyph,Move(dir)→step_object,Say→speech_bubbles,Scroll→active_scroll,AddGems(n)→player_gems,Die→remove_object(source)).step_objectandAction::Pushroute a grab-thing-shoved-into-the-player (viaBoard::pushed_grab_into_player) into a deferredgrab()instead of sliding the player, collected and fired in phase B alongsidebumps. Resolution is two-phase: mutate the board collecting(bumped, bumper)pairs, 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 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 aScrollaction:Text(String)(plain, word-wrapped) orChoice { choice, display }(selectable;choiceis sent back to the source object when the player picks it).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),Delay(f64)(never reaches the board queue — consumed byScriptHost::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),Swap(Vec<(i32,i32,i32,i32)>)(batch of simultaneous one-way solid moves; zero time cost),AddGems(i64)(changeGameState::player_gems, clamped at 0; zero cost),Die(remove the source object; zero cost).action_to_map(action) -> rhai::Map— converts anActionto a Rhai map forQueue.peek()/Queue.pop(). The map always has a"type"key; other keys carry the payload.Scroll/Swapemittypeonly (their payloads are not inspectable via the map API).Logis flattened to its first span's text.
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 runtime:
ScriptHost— owns the RhaiEngine, the compiled scripts referenced by a board's objects, a per-object persistentScope+ output queue + ready timer, and the shared board queue. Built withScriptHost::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'sscript_name(a world-pool name for a named script, or a syntheticBUILTIN_*name assigned byexpand_builtin_archetypesso identical built-ins, e.g. all pushers, share one AST); the source is the pool entry for that name, or the object's embeddedbuiltin_scriptwhen present. Reports compile/unknown-script failures onto the error sink; runs nothing.- Lifecycle hooks per object:
init()(zero-arg),tick(dt)(elapsed seconds asf64),bump(id)(the bumper'sObjectId, or-1for the player), andgrab()(zero-arg; fired when the player walks onto / pushes agrabthing into themselves), all optional — detected viaAST::iter_functions()by name and arity. Driven byrun_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 onRc<RefCell<Board>>(theBoardRefalias, exposed to Rhai under the type nameBoard) — getters only, so read-only by construction. A clone of the handle is pushed into each scope as the constantBoard.blocked(dir) -> boolis 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) -> boolis a read fn mirroringBoard::can_push: true if(x, y)holds a pushable whose chain can be shoved indir(false off-board).can_shift(x, y, dir) -> boolmirrorsBoard::can_shift: likecan_pushbut only checks the single cell ahead (pushable source + an empty-or-pushable next cell), the right gate for a simultaneous shift/rotation viaswap.passable(x, y) -> boolmirrorsBoard::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 (whichcan_shift/can_pushalone cannot).has_tag(s) -> boolandget_tags() -> Arrayread the calling object's tag set;objects_with_tag(s) -> Arrayreturns all object ids carrying that tag.my_name() -> Stringreturns the calling object's name (or""if unnamed);object_id_for_name(s) -> i64looks up an object by name and returns its id, or0if not found. Each getter briefly borrows the sharedBoard. - 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 anAction(Move/SetTile/Log/Say/Scroll/Push/Swap;MOVE_COST = 0.25s forMove, else 0 —push/swapadd no delay) to the issuing object's output queue (routed by the per-call tag via aHashMap<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 (seeBoard::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> 0nothing is pulled (this caps object speed);advance_timers(dt)counts them down each frame.GameStatedrains the board queue withtake_board_queue()and applies it after the batch — so scripts mutate without a&mut GameStateborrow. Errors (compile/runtime) bypass the queues via the error sink (take_errors()). - The
Queueobject (a handle to the calling object's output queue) is pushed into each scope; scripts callQueue.length(),Queue.clear(),Queue.peek()(front action as a Rhai map, or()if empty), andQueue.pop()(same, removes the front). Safe to mutate from script because the host only touches output queues between calls (duringpump). - Sender identity:
source(which object issued an action) rides the per-call tag —runcallscall_fn_with_options(...with_tag(object_id)...)and the host fns read it viaNativeCallContext::tag()(decoded back to anObjectIdbysource_of). Scripts writemove(North)without naming themselves;North/South/East/WestareDirectionconstants in scope, andimpl From<Direction> for (i32,i32)gives the delta. GameState(hence theEngine/Scope) is single-threaded / notSend; fine for kiln-tui.- Sender identity uses stable ids:
BoardAction.source,ObjectRuntime.object_id, and theQueueMapkeys are all the object'sObjectId(matchingBoard::objects'BTreeMapkeys), so they survive object spawn/destroy/reorder. The per-call tag carries the id as ani64(id 0 — never valid — is the no-source fallback).ScriptHost::objectsis still aVec<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, 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.