Appearance
Kiln design
The full design doc is the Claude Doc "Three.js ECS Game Framework — Design": https://claude.ai/artifact/MJzEQjKso9bHKcUYGu3zyD
This page records how the implementation maps to it, and where it deliberately differs.
Layers
| Layer | Where | Imports Three.js? |
|---|---|---|
| Core: app, loop, ECS wrap, schedule, events, snapshots | packages/kiln/src/core | No (enforced by a test) |
| Plugins: render, input, assets, debug, physics, timer, tween, ui, camera, save, animation | packages/kiln/src/plugins | render/assets/debug/camera/animation yes (except the animation graph); the rest no |
| Tooling: CLI, Vite plugin, Bun preload, scenario runner | packages/kiln/src/{cli,tooling,test} | — |
Community plugins (kiln add) | packages/kiln/community | yes |
Decisions as built
- Koota boundary: systems use Kiln's
system/Mut/commands/res/events;trait,relation,Not,Orare Koota's own. Koota has no staticChanged, so Kiln'sChanged/Added/Removed(T)create a separate Koota tracker per system.ctx.worldis a read-only proxy in dev. - Write checks:
Mut()types make undeclared row writes compile errors; in dev, row views throw on read-only writes,entity.setchecks the system'sMutset,entity.getcopies of struct traits throw on write, and structural entity calls inside systems throw with thecommandsfix. - Names come from exports through a runtime transform (
kiln/preloadfor Bun,kiln/vitefor the browser) that wrapsconst X = system(...)as__kiln_named("X", system(...)). - Events are double-buffered per phase block: a whole frame's Fixed steps count as one run, so every reader sees every event exactly once however many steps a frame runs.
app.events(E)reads a per-step()recorder. Registration isapp.init(E)(soapp.events(E)can stay the test reader). - Input phase is exported as
InputPhasebecauseInputis the resource. - Hot reload: modules that only define systems hot-swap in place (
'keep'); anything else reaches the entry, which rebuilds and restores a snapshot ('snapshot') or reloads ('keep',false). - Renderer:
WebGPURenderer(WebGL2 fallback), Three.js pinned to 0.186.1; TSL cheat sheet in the generated AGENTS.md; vendored community shader plugins as working examples. - Physics: Rapier 0.21 compat builds (3D, or 2D in
mode: '2d'). Deterministic on one machine; cross-machine determinism is deferred. - Runtime: Bun for CLI/tests/scripts, Vite for the browser; core uses no
Bun.*APIs. - Run conditions (
runIf(cond, ...items)) are schedule gates, not system options, so the same system can be gated at the add site like Bevy's.run_if. Conditions see{ res, time }; their resource reads are recorded forkiln describe, which prints them per system. - States are resources (
state([...], initial)). Transitions apply in a frame-start hook, once per frame;onEnter/onExitare conditions true for one frame (one step in Fixed), with the same edge rule asInput.pressed.despawnOnExitreplaces Bevy'sStateScoped. - Game plugins: timers, tweens and the UI layer are in CorePlugins (headless, cheap, and silent no-ops when unused, which agents would otherwise trip on); camera and save are opt-in because they take over the camera or need configuration. Timers, Delayed and tweens count fixed steps so they stay deterministic. The UI is retained data in a resource (assertable headless), drawn as DOM over the canvas;
kiln shotclips the page so the HUD is in the image. Save files hold the chosen resources and trait values only; Three.js objects are rebuilt by the game afterLoaded. - Animation: sprite and skeletal animation share one graph (
plugins/animation/graph.ts, no Three.js): states play clips, transitions test params, triggers and exit times, events sit on states. It ticks in Fixed like timers and tweens, so ahitFrameevent can drive gameplay deterministically. The graph computes crossfade weights and playheads itself; the skeletal plugin writes them into each mixer action and samples withmixer.update(0), instead of Three's own fades, so the weights are testable headless and match the graph exactly. Sprite quads own their geometry (UVs per frame) and share one material per sheet. Both are opt-in plugins; animators are AoS objects withtoJSONfor snapshots and are not part of saves. - Recordings (
core/recording.ts): a session is the seed and loop config plus everything that reaches the simulation from outside, so a replay needs no world snapshot: per-frame deltas (replayed withtick(dt), so the fixed-step accumulator splits frames exactly as it did live), input as the game saw it (action values, not keys, so devices and bindings don't matter), and side channels plugins note between frames (DOM HUD clicks; asset loads, which a replay holds back to the recorded frame; stored saves at start). Dev browser sessions record from boot; F8 or a crash POSTs to the Vite plugin, which writesrecordings/. A state hash (entities and Rng, rounded) every 60 frames locates drift. Physics is not made deterministic here: Rapier drift, andMath.*differences between V8 and JavaScriptCore, show up as an earlydivergedAtrather than being prevented. Snapshots skip Three.js objects even though they havetoJSON, so browser and headless states compare.