Taos Engine ▦ Taos: Building a Modern WebGPU Game Engine

Chapter 12: Game Engine Design

The game engine provides the structure for placing objects in the world and updating them each frame. Input — keyboard, mouse, and touch — and the controllers that consume it live in their own chapter (12-Input); this one focuses on the entity/component system, scene graph, frame loop, and world persistence.

12.1 The Component/Entity System#

GameObject as a transform-plus-components container, with three example objects (Player, Sun, Torch) showing different attached components

Taos uses a component/entity pattern, though it is simplified compared to pure ECS architectures. A GameObject is a container for components, each component adding a specific capability.

GameObject
├── Transform        — local TRS + lazily-cached world matrix
├── Camera           — view matrix, projection
├── MeshRenderer     — draws a mesh with a material
├── DirectionalLight — sun light with cascade data
├── PointLight       — point light source
├── SpotLight        — cone light source
├── PlayerController — first-person movement
└── AudioSource      — spatial audio emitter

12.2 GameObject and Component#

// ── from src/engine/game_object.ts ──
class GameObject {
  id: string;                     // stable identity (auto-generated; editor overwrites)
  enabled = true;
  name: string;
  readonly transform: Transform;  // owns local TRS + the cached world matrix
  parent: GameObject | null = null;
  children: GameObject[] = [];

  // Local TRS — forwarding accessors onto `transform`.
  get position(): Vec3;           // local-space, mutate in place
  get rotation(): Quaternion;
  get scale(): Vec3;

  // World-space, recovered from the parent chain (transform-owned, copy before holding).
  get worldPosition(): Vec3;
  get worldRotation(): Quaternion;
  get worldScale(): Vec3;
  getWorldPosition(out?: Vec3): Vec3;    // allocation-free variants

  addComponent<T extends Component>(c: T): T;
  getComponent<T extends Component>(ctor: new (...args: never[]) => T): T | null;
  getComponents<T extends Component>(ctor: new (...args: never[]) => T): T[];
  addChild(child: GameObject): void;
  localToWorld(): Mat4;                   // fresh copy of the cached world matrix
  localToWorldInto(out: Mat4): Mat4;      // allocation-free, for per-frame loops
}

Every GameObject owns a Transform (§12.2.1). The position / rotation / scale properties are forwarding accessors onto it — code that writes go.position.y += dt is really mutating go.transform's local position. The id is a stable, per-session identity: auto-generated for runtime objects, but overwritten by the editor with a persistent id so references, selection, and prefab links survive save / load / undo.

Components attach to a GameObject and implement lifecycle methods:

// ── from src/engine/component.ts ──
abstract class Component {
  gameObject!: GameObject;
  get time(): Time;                    // Scene clock (delta, elapsed, frameCount, scale)

  onAttach(): void {}                  // Called when addComponent binds this instance
  onDetach(): void {}                  // Called when removeComponent runs
  update(dt: number): void {}          // Called every frame
  updateRender(ctx: RenderContext): void {}  // Per-frame refresh of render-side caches
}

update(dt) drives simulation. updateRender(ctx) runs immediately afterwards and is where components refresh GPU-facing state that depends on the final transform — Camera, for instance, rebuilds its view + projection matrices here so render passes always see a consistent snapshot.

The dt argument is the only number update receives, but a component often needs more than the inter-frame delta — absolute elapsed time for a sine-wave bob, the frame counter, or a timeScale it can honor for pause and slow-motion. Rather than widening every update signature, the engine exposes a per-scene clock through the Component.time getter (see §12.4 Time). this.time.delta is identical to the dt argument, so a component can ignore dt entirely and read everything off this.time.

12.2.1 The Transform: caching and compare-on-read invalidation#

The job of a transform looks trivial — store position, rotation, and scale; hand back a world matrix — but doing it cheaply every frame, for thousands of objects in a parent/child tree, is where the design earns its keep. The world matrix of any object is the product of its own local TRS matrix with its parent's world matrix, recursively up to a root:

worldMatrix = parent.worldMatrix × localMatrix          (root: worldMatrix = localMatrix)

Recomputing that product from scratch every read is wasteful — a static prop's matrix never changes, and a moving object usually only moves itself, not its whole subtree. So the Transform caches both the local matrix and the world matrix, and only redoes the matrix math for transforms that actually moved. The interesting question is how it knows what moved.

Compare-on-read, not write-interception#

The usual answer is a dirty flag: intercept every write to position/rotation/scale, set a boolean, and recompute on the next read. That requires the transform to own the writes — typically by hiding the fields behind setters or wrapping the vectors in observable proxies. Taos deliberately doesn't do that, because the entire codebase mutates transforms by direct field access:

go.position.y += dt * speed;        // component gameplay
go.position.set(x, y, z);           // controllers
this.gameObject.rotation.setAxisAngle(AXIS_Y, angle);

position is a plain mutable Vec3; none of those writes route through the Transform, so there's no hook on which to hang a dirty flag. Rewriting every call site to go through transform.setPosition(...) — and forbidding pos.y += ... — was the alternative, and it was rejected. Instead, invalidation is compare-on-read: nothing is marked on write, and worldMatrix detects change when it is read.

The transform keeps the ten scalars (3 position + 4 quaternion + 3 scale) that were last baked into its local matrix. On read, it compares the current TRS scalars against those baked values; a mismatch means the local TRS changed since the last bake, so the local matrix is rebuilt:

// ── from src/engine/transform.ts (_refreshLocal) ──
private _refreshLocal(): boolean {
  const p = this._position, r = this._rotation, s = this._scale;
  if (
    p.x === this._bpx && p.y === this._bpy && p.z === this._bpz &&
    r.x === this._brx && /* …7 more… */ s.z === this._bsz
  ) {
    return false;                      // nothing changed → no work
  }
  this._localMatrix.setTrs(p, r.x, r.y, r.z, r.w, s);
  this._bpx = p.x; /* …snapshot all 10 scalars… */
  return true;                         // rebuilt
}

The ten baked scalars start as NaN, and because NaN !== NaN the very first read always counts as a change and bakes the matrix once. The cost of "did this transform move?" is ten float comparisons — far cheaper than a setTrs (which builds a rotation matrix and three scaled basis columns), so the comparison pays for itself the moment an object holds still for even one extra frame.

Propagating parent movement with a version counter#

A local-scalar comparison catches self movement, but a child must also rebuild when an ancestor moves, even though its own local TRS is untouched. Walking to the root and re-multiplying on every read would defeat the purpose. Instead, each transform carries a _worldVersion integer that it bumps every time it recomputes its own world matrix. A child remembers the parent version it last folded in (_parentVersionSeen); when the two differ, the parent has moved since the child last looked:

// ── from src/engine/transform.ts (worldMatrix) ──
get worldMatrix(): Mat4 {
  let dirty = this._refreshLocal() || !this._worldComputed;
  const parent = this.gameObject.parent;
  if (parent) {
    const pw = parent.transform.worldMatrix;          // recurse up the chain first
    if (!this._hadParent || parent.transform._worldVersion !== this._parentVersionSeen) {
      dirty = true;
      this._parentVersionSeen = parent.transform._worldVersion;
    }
    if (dirty) {
      this._worldMatrix.setMultiply(pw, this._localMatrix);
    }
  } else if (dirty) {
    this._worldMatrix.copyFrom(this._localMatrix);
  }
  if (dirty) {
    this._worldComputed = true;
    this._worldVersion++;                              // children downstream will notice
  }
  return this._worldMatrix;
}

Reading worldMatrix therefore walks to the root, but at each level it does only cheap scalar/integer comparisons; it performs an actual matrix multiply only on the levels that genuinely changed. Move the root of a 1000-node tree and every descendant correctly rebuilds; move one leaf and only that leaf's chain recomputes — the siblings, queried later in the frame, see matching versions and return their cached matrices untouched.

This also handles re-parenting and detaching without any explicit notification. _hadParent records whether the previous read saw a parent: gaining one (newly added child), losing one (detached to a root, where the world matrix reverts to the local matrix), or being moved under a different parent all surface as a dirty recompute on the next read, because either _hadParent flips or the new parent's version won't match the remembered one.

Layered caches: inverse and world-space TRS#

Two further derived values hang off the same version counter, each recomputed only when _worldVersion advances:

  • worldToLocalMatrix — the inverse of the world matrix (used to bring a world-space point or ray into local space). Inversion is expensive, so it's memoized against _worldVersion and recomputed lazily on first use after a change.
  • worldRotation / worldScale — the rotation and scale decomposed back out of the world matrix (recovering an object's absolute orientation through a chain of parent rotations and scales). The decomposition — extracting per-axis basis lengths, folding a mirrored determinant's sign into X, then converting the normalized basis to a quaternion — is the heaviest operation on the class, so it too runs only when the world matrix actually changed, guarded by a _trsVersion that tracks _worldVersion.

worldPosition is the exception: it's just the translation column of the world matrix (data[12..14]), so there's nothing to decompose — it's read directly every time.

Allocation discipline#

The world getters return objects owned by the transformworldPosition hands back a reused internal Vec3, not a fresh one. That keeps per-frame reads allocation-free, but it means a caller who stows the result and reads it next frame sees stale (or overwritten) data. For callers that need to keep a value, the getWorld*(out?) variants write into a caller-supplied target (allocating only when out is omitted):

const p = go.getWorldPosition(this._scratch);   // writes into _scratch, no allocation

The same split applies to the matrix itself: localToWorld() returns a fresh Mat4 clone for convenience, while localToWorldInto(out) copies the cached matrix into the caller's storage — the form the engine's per-frame draw-bucketing and light updates use, so a full scene walk allocates nothing.

12.3 The Scene Graph#

Hierarchy of GameObjects with parent-child links, plus a worked-out localToWorld() matrix multiplication walking from HeldTorch up through Player to root

The Scene class manages the hierarchy of GameObjects:

// ── from src/engine/scene.ts ──
class Scene {
  readonly gameObjects: GameObject[];          // Root-level GameObjects
  readonly time: Time;                         // Simulation clock for this scene

  add(go: GameObject): GameObject;             // Add a root GameObject (returns it, for chaining)
  remove(go: GameObject): void;                // Remove a root GameObject

  update(dt: number): void;                    // Recursively ticks every enabled component
  updateRender(ctx: RenderContext): void;      // Per-frame render-side refresh

  findCamera(): Camera | null;                 // First Camera in the hierarchy
  findSunLight(): DirectionalLight | null;     // First DirectionalLight in the hierarchy
  collectMeshRenderers(): MeshRenderer[];      // Walks the full hierarchy
  getComponents<T>(ctor): T[];                 // One per root (first match)
}

The scene is a forest of root GameObjects, each of which can have arbitrary children. Children inherit their parent's transform via GameObject.localToWorld, which multiplies up the chain from the leaf to the root — but only re-multiplying the levels that actually moved, thanks to the compare-on-read caching in §12.2.1.

12.4 The Engine and the Game Loop#

Engine.frame() phases per requestAnimationFrame tick: beforeFrame → scene.update → bucket draws → feature.earlyUpdate → feature.update → fresh RenderGraph → feature.addPasses → beforeRender → compile + execute → afterFrame

The frame loop is owned by the Engine class. The application hands Engine.create a canvas and a render preset — a function that registers a coordinated bundle of RenderFeature instances — and then calls engine.run() to start a requestAnimationFrame loop. Driving the scene, bucketing draw items, feeding uniform updates to passes, and rebuilding the graph all happen inside a single engine.frame() call:

// ── from src/engine/engine.ts ──
async frame(): Promise<void> {
  ctx.update();                                  // 1. canvas resize, dt
  this.scene.time.advance(...);                  //    advance the scene clock (dt = time.delta)
  this.scene.update(dt);                         // 2. simulation tick
  this.scene.updateRender(ctx);                  // 3. component render-side refresh
  this._bucketScene();                           // 4. opaque / transparent / shadow buckets

  for (const f of features) f.earlyUpdate?.(frame);  // 5. mutate camera/uniforms early
  for (const f of features) f.update?.(frame);       // 6. per-frame uniform refresh

  const graph = new RenderGraph(ctx, cache);     // 7. fresh per-frame graph
  for (const f of features) f.addPasses(frame);  // 8. each feature wires its passes
  for (const cb of beforeRender) cb(frame);      // 9. one-off inline passes

  const compiled = graph.compile();              // 10. validate + cull + sort + bind
  await graph.execute(compiled);                 // 11. one command encoder, one submit
}

The scene walk, draw-bucketing, and camera-matrix caching all live in the engine, so applications don't drive them. And the per-frame state every pass needs — buckets, camera, time, the live graph, the chained HDR handle — is exposed as a single Frame object that every feature receives.

Render Features#

A RenderFeature is one unit of rendering work — usually one or two related passes plus the per-frame update and addPasses logic that owns them — bundled into a self-contained object:

// ── from src/engine/render_feature.ts ──
interface RenderFeature {
  readonly name: string;          // 'ShadowFeature', 'BloomFeature', ...
  enabled: boolean;               // Toggle without removing
  setup(engine): void | Promise<void>;
  earlyUpdate?(frame: Frame): void;
  update?(frame: Frame): void;
  addPasses(frame: Frame): void;
  destroy?(): void;
}

Lifecycle:

  1. setup(engine) runs once when the feature is added. Construct the pass instance(s), allocate any persistent GPU buffers.
  2. earlyUpdate(frame) runs across all features before any update — used for things that must mutate camera/uniform state before downstream features sample it (TAA's sub-pixel jitter is the canonical example).
  3. update(frame) runs once per frame after bucketing. Push per-frame uniforms to the owned passes.
  4. addPasses(frame) runs while the graph is being built. Read frame.hdr / frame.gbuffer / frame.shadowMap, call pass.addToGraph(graph, ...), and write the new handles back into frame.hdr so downstream features pick them up.
  5. destroy() releases the pass's long-lived GPU resources.

Features chain through the Frame object — each one reads the current frame.hdr, declares its pass, and reassigns the slot. The compiler sees a clean linear dependency.

A left-to-right chain of RenderFeature boxes, each reading the single frame.hdr handle off a shared bus and writing back a new version, forming a linear dependency

Presets — Composing Features#

A preset is a function that registers a bundle of features on an engine. The three built-ins live in src/renderer/presets/:

// ── from src/renderer/presets/deferred_preset.ts (excerpt) ──
export function deferredPreset(opts: DeferredPresetOptions = {}): RenderPreset {
  return (engine: Engine): void => {
    if (opts.shadow !== false) engine.addFeature(new ShadowFeature(opts.shadow));
    engine.addFeature(new GeometryFeature());
    if (opts.ao !== false) engine.addFeature(new AOFeature({ method: opts.ao ?? 'ssao' }));
    registerSkyFeature(engine, opts.sky);           // 'none' | 'color' | 'texture' | 'atmosphere'
    engine.addFeature(new DeferredLightingFeature({ ibl: opts.ibl, ...opts.lighting }));
    if (opts.pointSpotLights) engine.addFeature(new PointSpotLightFeature());
    if (opts.transparent ?? true) engine.addFeature(new ForwardOverlayFeature({
      plus: opts.overlayLighting === 'forward+',    // transparency overlay backend
    }));
    if (opts.taa ?? true) engine.addFeature(new TAAFeature());
    if (opts.dof)   engine.addFeature(new DofFeature(...));
    if (opts.bloom) engine.addFeature(new BloomFeature(...));
    engine.addFeature(new TonemapFeature({ exposure: opts.exposure, aces: opts.aces }));
  };
}

All three presets share the same option vocabulary where it makes sense: sky (a tagged SkyOption with kind: 'none' | 'color' | 'texture' | 'atmosphere'), shadow, ibl, taa, dof, bloom, exposure, aces, hdrCanvas. forwardPreset is the smallest — sky → optional shadow → forward-lit → optional TAA/DoF/Bloom → tonemap — for simple material-showcase scenes. forwardPlusPreset sits in the middle: optional sky → optional shadow → ForwardPlusFeature (the tiled cull + shading pair) → tonemap, picked when the scene has many small point lights or needs forward-native transparency/MSAA. deferredPreset adds the path-specific knobs: ao, transparent, pointSpotLights, lighting, and overlayLighting: 'forward' | 'forward+'.

That last option picks which forward pass shades the transparency overlay. 'forward' (the default) uses ForwardPass. 'forward+' swaps in ForwardPlusPass with the deferred gbuffer depth wired as the tile-cull pre-pass's input — useful when the transparent layer needs to see hundreds of point lights without the per-fragment fixed loop blowing up.

Presets are pure convenience constructors: they call engine.addFeature(...) in registration order. Applications that don't fit any of the shipped shapes skip the preset and call addFeature directly. The crafty game does exactly this — it registers a single CraftyPipelineFeature that wires the full voxel-world pipeline (chunk geometry, weather particles, godrays, clouds, block highlight) without using a preset at all.

A Concrete Frame#

A complete forward-rendered hello-world fits in twenty lines:

// ── samples/rg_forward_simple.ts pattern ──
const engine = await Engine.create({
  canvas,
  renderPreset: forwardPreset({ sky: { kind: 'texture', texture: skyTexture }, ibl }),
});

const sphere = new GameObject({ name: 'Sphere' });
sphere.addComponent(new MeshRenderer(sphereMesh, sphereMaterial));
engine.scene.add(sphere);

const sun = new GameObject({ name: 'Sun' });
sun.addComponent(new DirectionalLight(direction, color, intensity));
engine.scene.add(sun);

const cameraGO = new GameObject({ name: 'Camera' });
cameraGO.setPosition(0, 3, -6);
cameraGO.addComponent(Camera.createPerspective(60, 0.1, 100, aspect));
engine.scene.add(cameraGO);

engine.run();

The engine resolves the camera on the first frame via scene.findCamera(), so the application doesn't even set engine.camera. After that, every frame:

  • the scene walks itself, ticks components, refreshes render-side state, and gets bucketed into opaque / transparent / shadow lists,
  • every enabled feature runs update(frame) to push per-frame uniforms,
  • the engine creates a fresh RenderGraph and asks every feature to addPasses(frame),
  • the graph compiles, executes, and submits one command buffer.

Hooks Around the Frame#

For work that doesn't fit a feature — input handling, UI updates, one-off debug overlays — the engine exposes three callbacks:

engine.beforeFrame((frame) => {/* top of frame, before scene.update */});
engine.beforeRender((frame) => {/* while graph builds, after features added passes */});
engine.afterFrame((frame) => {/* after submission */});

A controller that mutates transforms (camera, player) is usually best modeled as a Component on the object it drives — scene.update then ticks it for free (this is what CameraController does when you addComponent it). Reach for beforeFrame when something must run before scene.update reads the transforms, e.g. a controller that has to update ahead of other per-frame work in the same callback (the geo tutorials update the camera here right before floating-origin reanchoring). beforeRender is the escape hatch for one-off inline passes that don't justify a full feature class — declare a pass via frame.graph.addPass(...) and reassign frame.hdr if needed. afterFrame runs after the command buffer is submitted; it's where FPS readouts and the render-graph viz overlay live.

Time#

Each Scene owns a Time clock that the engine advances once per frame, immediately after ctx.update() and before scene.update. Components reach it through this.time; applications can reach it through engine.time (a shorthand for scene.time).

// ── from src/engine/time.ts ──
class Time {
  scale = 1;            // 1 = real time, 0 = paused, 0.5 = slow-mo, 2 = double speed

  delta = 0;            // scaled seconds since last frame — this is the `dt` passed to update()
  unscaledDelta = 0;    // real seconds since last frame, ignoring scale
  elapsed = 0;          // scaled seconds since start (stops while paused)
  unscaledElapsed = 0;  // real seconds since start
  frameCount = 0;       // total frames since start
  fps = 0;              // smoothed frames-per-second

  advance(unscaledDelta, unscaledElapsed, frameCount, fps): void;  // called by the engine
}

The split between scaled and unscaled is the point of the abstraction. delta and elapsed are multiplied by scale, so gameplay that reads them obeys a single global knob — set engine.time.scale = 0 to pause every behavior at once, or 0.25 for bullet-time. State that must keep moving regardless (UI animation, a free-look camera, the pause menu itself) reads unscaledDelta / unscaledElapsed instead.

The engine sets the dt argument to time.delta, so the value handed to update(dt) and the value read from this.time.delta can never disagree:

override update(): void {                     // dt argument now optional
  this.angle  += this.speed * this.time.delta;       // gameplay — respects pause / slow-mo
  this.glow    = Math.sin(this.time.elapsed);        // absolute simulation time
  this.bob     = Math.sin(this.time.unscaledElapsed); // keeps moving even when paused
}

Because the clock lives on the Scene rather than in a module-level global, separate engine instances on the same page (multiple canvases, a picture-in-picture preview) each keep their own independent time and scale.

Frame Rate#

The frame rate is uncapped — engine.run() is a requestAnimationFrame loop, so it ticks at the display refresh. dt is clamped to 100 ms inside engine.frame() to prevent physics explosion on tab-switch:

// ── from src/engine/engine.ts ──
const dtRaw = Math.min(ctx.deltaTime, 0.1);                 // clamp the raw frame delta
this.scene.time.advance(dtRaw, ctx.elapsedTime, ctx.frameCount, ctx.fps);
const dt = this.scene.time.delta;                           // scaled delta drives the sim

Applications that drive their own loop (XR session, screenshot automation) call engine.frame() directly instead of engine.run(). frame() resolves once the command buffer has been submitted.

12.5 World Persistence (IndexedDB)#

Crafty saves local worlds in the browser's IndexedDB via the WorldStorage class (crafty/game/world_storage.ts). Each world is stored as a single record in a worlds object store keyed by a UUID:

// ── from crafty/game/world_storage.ts ──
export interface SavedWorld
{
  id: string;
  name: string;
  seed: number;
  createdAt: number;
  lastPlayedAt: number;
  edits: BlockEdit[];
  player: { x: number; y: number; z: number; yaw: number; pitch: number };
  sunAngle: number;
  /** Game mode. Missing (pre-survival saves) loads as 'creative'. */
  gameMode?: GameMode;
  /** Persisted survival inventory: gathered stacks + the exact hotbar layout.
   *  Only written for survival worlds; restored on load. */
  inventory?: InventoryState;
  /** Moon phase in [0, 1] (0/1 = new, 0.5 = full). */
  moonPhase?: number;
  /** JPEG thumbnail (~160×90), updated less frequently than the rest of the record. */
  screenshot?: Blob;
  /** Schema version; missing means pre-versioning (treat as 0). */
  version?: number;
}

Database Schema#

The IndexedDB database is opened with a single object store. An onupgradeneeded handler creates the store if it doesn't exist — IndexedDB ignores duplicate creations, so repeated opens are safe:

// ── from crafty/game/world_storage.ts ──
static open(): Promise<WorldStorage>
{
  return new Promise<WorldStorage>((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, DB_VERSION);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(STORE)) {
        db.createObjectStore(STORE, { keyPath: 'id' });
      }
    };
    req.onsuccess = () => resolve(new WorldStorage(req.result));
    req.onerror = () => reject(req.error ?? new Error('IndexedDB open failed'));
  });
}

CRUD Operations#

All operations follow a consistent pattern: begin a transaction, get the object store, and wrap the request in a promise:

// ── from crafty/game/world_storage.ts ──
save(world: SavedWorld): Promise<void>
{
  return this._withStore('readwrite', (store) => {
    return new Promise<void>((resolve, reject) => {
      const req = store.put(world);
      req.onsuccess = () => resolve();
      req.onerror = () => reject(req.error ?? new Error('IndexedDB save failed'));
    });
  });
}

list(): Promise<SavedWorld[]>
{
  return this._withStore('readonly', (store) => {
    return new Promise<SavedWorld[]>((resolve, reject) => {
      const req = store.getAll();
      req.onsuccess = () => {
        const all = (req.result ?? []) as SavedWorld[];
        all.sort((a, b) => b.lastPlayedAt - a.lastPlayedAt);
        resolve(all);
      };
      req.onerror = () => reject(req.error ?? new Error('IndexedDB list failed'));
    });
  });
}

Results are sorted by lastPlayedAt descending so the launcher can show the most recently played world first. The _withStore helper reduces boilerplate by wrapping the transaction + object store setup:

// ── from crafty/game/world_storage.ts ──
private _withStore<T>(mode: IDBTransactionMode, fn: (store: IDBObjectStore) => Promise<T>): Promise<T>
{
  const tx = this._db.transaction(STORE, mode);
  const store = tx.objectStore(STORE);
  return fn(store);
}

Schema Versioning#

A version field on each record enables forward-compatible schema changes. Records with a missing or lower version are detected and migrated when loaded:

// ── from crafty/game/world_storage.ts ──
export const CURRENT_FORMAT_VERSION = 1;

The version check happens in the load path — if loaded.version < CURRENT_FORMAT_VERSION, the application applies upgrade transforms before exposing the record. This avoids maintaining migration logic inside the storage layer itself.

World Record Lifecycle#

Worlds are created via the factory function createSavedWorld, which populates sensible defaults for a fresh world:

// ── from crafty/game/world_storage.ts ──
export function createSavedWorld(name: string, seed: number, gameMode: GameMode = 'creative'): SavedWorld
{
  const now = Date.now();
  return {
    id: _randomId(),
    name,
    seed,
    createdAt: now,
    lastPlayedAt: now,
    edits: [],
    player: { x: 64, y: 80, z: 64, yaw: 0, pitch: 0 },
    sunAngle: Math.PI * 0.3,
    gameMode,
    version: CURRENT_FORMAT_VERSION,
  };
}

During gameplay, the world record is updated periodically (every ~5 seconds) through a debounced autosave in main.ts. The edits array grows monotonically — every block placement or destruction appends a BlockEdit entry. This gives a complete undo history and enables replay-based persistence: the world is re-generated from the seed plus the edit log rather than storing the full block grid.

The start screen uses storage.list() to populate the saved-world selector and storage.delete() to remove worlds, with the screenshot Blob providing a visual thumbnail for each entry.

Survival Inventory#

Survival worlds also persist what the player has gathered. The counted inventory lives in crafty/game/inventory.ts as a Map<BlockType, number> (counts keyed by block type, in acquisition order), and the on-screen hotbar (crafty/ui/hotbar.ts) owns a parallel slot layout — which block type sits in each of the nine slots. The same autosave that flushes position and gameMode captures both into SavedWorld.inventory:

// ── from crafty/main.ts (_flushSave) ──
savedWorld.gameMode = inventory.mode;
// Only survival worlds carry an inventory; clear it in creative so a world
// toggled back to creative doesn't keep a stale stack list.
savedWorld.inventory = inventory.mode === 'survival'
  ? { stacks: inventory.items().map(s => ({ blockType: s.type, count: s.count })),
      hotbar: [...hotbar.slots] }
  : undefined;

On load, inventory.restore(...) repopulates the counts and hotbar.setSlots(...) rebuilds the bar exactly as it was left, so reopening a world restores both what you carry and where it sits on the bar. Creative worlds store no inventory (every block is unlimited), so the field stays absent and the launcher's pre-survival saves keep working unchanged. The InventoryState shape ({ stacks, hotbar }) is shared with the multiplayer protocol — in a networked survival world the same payload is persisted per-player on the server instead (see Chapter 16 §16.5).

12.6 Summary#

The game engine layer provides:

  • Entity/component system: GameObject + Component with scene graph tree hierarchy; each GameObject owns a Transform that caches its world matrix and invalidates by compare-on-read (no dirty flags), recomputing only the levels of the parent chain that actually moved
  • Per-scene time: a Time clock (delta, elapsed, frameCount, scale) reachable from any component via this.time, with timeScale for global pause and slow-motion
  • Engine + features + presets: Engine.frame() owns the per-frame loop; RenderFeatures bundle pass instances with the per-frame update + graph-wiring logic; presets compose features into ready-made forward and deferred pipelines
  • World persistence: IndexedDB-based storage with schema versioning, autosave, and edit-log replay

Input — keyboard, mouse, pointer lock, and the touch overlay — and the controllers that consume it (camera and player) live in Chapter 13: Input.

Further reading: