← All posts

Awaken architecture: the core document

This is the map - the post I'll keep pointing back at when later ones say "…and that's where the ECS comes in." I've tried to keep it readable rather than exhaustive, but it's the load-bearing one, so it's worth a slow coffee. Everything below is bent by a single constraint that turned out to be weirdly clarifying: the editor, the renderer, the physics and the exported game all have to run in a browser tab, on WebGPU, with nothing installed. When you can't reach for a thread pool or a native file dialog, a lot of tempting-but-wrong designs just quietly disappear from the menu.

Packages

Awaken is a monorepo with a strict dependency direction. The rule that makes everything else work: core depends on nothing - no GPU, no DOM, just ECS and maths.

flowchart TD
  core["@awaken/core
ECS · maths · serialization
(no GPU, no DOM)"] render["@awaken/render
WebGPU renderer"] runtime["@awaken/runtime
physics · scripting · systems"] assets["@awaken/assets
importers · file formats"] editor["apps/editor
React editor"] player["apps/player
standalone game runtime"] render --> core runtime --> core assets --> core assets --> render editor --> core & render & runtime & assets player --> core & render & runtime & assets

Because core has no idea whether it's inside an editor or a stripped-down player, the same scene data runs in both. That single fact is what makes "export a game" tractable (post #4).

The ECS: sparse sets + generational handles

Entities are plain numbers. Components live in sparse sets - a dense data array plus an entity→slot map - so iterating all live instances of a type is contiguous and never touches dead entities. Removal is swap-and-pop: O(1), no holes.

flowchart LR
  W["World"] --> S1["ComponentStore<Transform>"]
  W --> S2["ComponentStore<MeshRenderer>"]
  W --> S3["ComponentStore<RigidBody>"]
  S1 --> D1["dense: [data…]
index: entity → slot"]

An entity handle packs an id and a generation into one JS number:

// id (low 24 bits) + generation (× 2^24). Stays < 2^48 → an exact JS number.
const makeEntity = (id, gen) => id + gen * GEN_MULT;   // GEN_MULT = 2^24

Destroying an entity bumps its slot's generation and recycles the id, so any stale handle to the old occupant fails an isAlive check - a recycled slot can never be mistaken for its predecessor. This is a performance choice (numbers, no allocation) that is also a correctness guarantee. It matters again in post #8, where undo restores entities under new handles.

Components declare their data; the editor is generated from it

Components aren't classes with methods - they're a name, a create() factory, and a list of typed fields:

export const MeshRenderer = defineComponent("MeshRenderer", [
  { name: "mesh",      type: "assetRef", default: "cube" },
  { name: "color",     type: "color",    default: [0.8, 0.8, 0.85] },
  { name: "metallic",  type: "number",   default: 0.0 },
  { name: "roughness", type: "number",   default: 0.6 },
]);

That declaration is reflection, and the inspector is generated from it: the editor walks the registry, maps each field to a row, and a single Control component switches on field.type to render the widget (number scrubber, colour picker, enum dropdown, entity picker). Nothing in that path names a specific field - add { name: "opacity", type: "number" } to a component and its inspector row appears with no editor change. Behaviour lives in systems ((world, dt) => void), never on the data.

📷 Screenshot needed: the Inspector panel showing a couple of components (e.g. Transform + MeshRenderer) with their auto-generated field rows - to illustrate "the UI is the data declaration."

Editor and shipped game are the same runtime

This is the boundary that pays off everywhere. "Play in the editor" and "the exported game" aren't two implementations that happen to agree - they construct the same PlayController from the same Runtime, the same component registry, the same physics backend and audio engine:

flowchart TD
  scene["Scene data
(World + assets)"] --> PC["PlayController
(Runtime + registry + physics + audio)"] PC --> ED["Editor: Play mode
+ UI panels, gizmos, undo"] PC --> PL["Player: game.html
+ frame loop only"]

The editor is that runtime with UI bolted on; the player is that runtime with a frame loop and nothing else. There is no separate "export runtime" to keep in sync - there's one runtime, hosted two ways.

The frame loop: work proportional to what moved

The naive loop asks "which of my 15,000 objects moved?" every frame - O(scene) before drawing anything. Awaken splits change into two explicit channels:

  • Structural change (create/destroy, add/remove component, re-parent) bumps a counter, structureRev.
  • Motion (a system/script/physics writing a Transform) calls markMoved(entity).

A direct field write (t.position.x += …) deliberately does neither - it's caught by the transform sync, not a topology rebuild. So the loop is a three-way branch:

flowchart TD
  F["Frame"] --> Q{"structureRev changed?"}
  Q -->|yes| R["rebuild draw cache
(full computeWorldMatrices)"] Q -->|no| M{"anything moved?"} M -->|yes| U["update moved subtrees only
+ patch their GPU rows"] M -->|no| N["do nothing (~0 cost)"]

A scene of 15,000 static props with one spinning windmill costs one matrix update per frame; a paused scene costs essentially zero. Making "structural" and "moved" orthogonal, explicitly-signalled channels - and making the common case (a field write) free - is what keeps a real-sized world smooth on a laptop or phone.

Undo is a command stack

Every edit is a Command with do/undo; do doubles as redo. Field edits capture the previous value (deep-cloned so undo can't alias live data); structural edits snapshot subtrees and rebuild them on undo. Commands compose, which is how a whole editor-script run collapses to one Ctrl-Z (post #8).

That's the skeleton. Everything else is a layer on top of it - importers fill the World, export serialises it, scripting and physics are systems over it, and the renderer draws it.

← All posts