Skip to content

Architecture

SAGE is one monorepo holding two packages a game depends on, plus a Vue integration. Which package a piece of code belongs to is decided by whether it needs a browser and a renderer.

Packages

@skewedaspect/sage-core is the entity system. It holds GameEntity, Behavior, the event map and EventBus, GameSimulationContext, GameSimulationManager, snapshot and restore, tags, the provided behaviors NamedBehavior and StateMachineBehavior, and the action declaration type. It ships as its own package, and it imports nothing from Babylon and nothing from the DOM.

@skewedaspect/sage is the Babylon package. It holds NodeEntity, mesh loading, levels and their scenes, physics, InputManager and everything it owns, audio, pooling, the parent-child hierarchy, debug tooling, and the save manager.

@skewedaspect/sage-vue binds a running engine to a Vue application: the canvas component and the composables a UI reads through.

The dependency direction is sage to sage-core, never the reverse. sage-vue depends on sage.

+---------------------------------------------------------------+
|  @skewedaspect/sage-vue      SageCanvas, composables           |
+---------------------------------------------------------------+
|  @skewedaspect/sage          NodeEntity, NodeSimulation,       |
|                              levels, physics, InputManager,    |
|                              audio, pooling, saves, debug      |
+---------------------------------------------------------------+
|  @skewedaspect/sage-core     GameEntity, Behavior, EventBus,   |
|                              GameSimulationManager, snapshots  |
+---------------------------------------------------------------+

What runs where

sage-core runs anywhere JavaScript runs. A server may construct a simulation from it alone, ticking GameEntity classes with no scene, no meshes, and no renderer. A client runs the same simulation manager, the same entity classes, and the same behaviors under Babylon, and a client's prediction simulation runs another instance of it.

A server that wants levels, mesh geometry, or physics runs all of sage on Babylon's NullEngine. createGameEngine(null, entityClasses, { havokBinary }) boots that way: passing null for the canvas builds a NullEngine, and havokBinary hands Havok its WASM bytes, since under Node there is no HTTP to fetch them from.

Layering

A file's package is decided by what it needs from its environment. Anything that touches Babylon, the DOM, a device, or an audio context lives in sage. Anything that needs none of them lives in sage-core.

Managers own state and coordinate. The simulation manager owns the entity list, the event bus, the entity class table, the tick counter, and the accumulator, and it implements GameSimulationContext. Entities and behaviors never reach a manager. An entity is created with a GameSimulationContext and holds nothing else from outside, and a behavior sees only this.entity.

Nothing in sage-core is named bare Entity. The base class is GameEntity, and every type that belongs to it is named GameEntity*.

How a game consumes SAGE

A game writes entity classes and behaviors in TypeScript. Entity classes that need no scene node extend GameEntity; the rest extend NodeEntity and get a Babylon TransformNode in the level's scene. Each class is registered by name in the entity class table the simulation is constructed with:

typescript
import { createGameEngine } from '@skewedaspect/sage';

const entityClasses = { PlayerEntity, EnemyEntity, CollectibleEntity };
const engine = await createGameEngine(canvas, entityClasses, { logLevel: 'info' });

A NodeEntity's mesh comes from a mesh source, handed to engine.rebuildSimulation(scene, meshSource) alongside the scene it is building a simulation for. GameLevel makes that call once its scene exists, using the level config's meshSource; a custom Level subclass makes it itself. Mesh content is level territory, not engine-construction config. A mesh source is optional; left unset, a spawned entity gets a node with no mesh.

The game spawns entities by class name, reads their state, and calls their operations:

typescript
const simulation = engine.simulation;

if(simulation !== null)
{
    const player = simulation.spawn('PlayerEntity', undefined, { health: 100 });
    const enemies = simulation.getEntitiesByTag('hostile');
}

The caller owns the clock. GameManager's frame loop hands runStepsForElapsedTime the frame's elapsed time, and the simulation converts that into whole fixed steps, running every entity's update in insertion order and then draining the bus. What an update means belongs to the entity; the loop knows nothing about the game. Frame callbacks registered through gameManager.registerFrameCallback run after the fixed steps and before the render.

Input reaches entities the same way everything else does. InputManager captures device state, turns it into actions through bindings and action contexts, and broadcasts each fired action to every entity subscribed to it, once a rendered frame, before that frame's fixed steps run.

One frame

render loop tick
  |
  |-- InputManager.routeCapturedInput      every live binding's fire, broadcast as action:<name>
  |
  |-- simulation.runStepsForElapsedTime    for each whole fixed step:
  |       every entity's update() in insertion order
  |       bus.drain()                      queued events delivered, FIFO
  |
  |-- frame callbacks                      registerFrameCallback, in registration order
  |
  '-- scene.render()

Two event buses

The simulation bus belongs to sage-core. Entities register on it, behaviors subscribe through static events, and every emission queues until the drain. Its vocabulary is closed: a game declares its event types by augmenting EventMap. See Events.

engine.eventBus is sage's own GameEventBus, unchanged from 0.9. Levels, asset loading, triggers, and pause and resume publish there, and a game subscribes to those with engine.eventBus.subscribe. Entity events do not travel on it.

Determinism

SAGE's own code calls no Math.random and no Date.now, and nothing in a fixed step iterates without a defined order: entities update in insertion order, behaviors run in attachment order, and events drain first in, first out. Replaying fixed steps from a snapshot produces the same state as running them straight through. A behavior that rolls or reads the wall clock receives that from the game as an argument or a state field.

Where to go next

  • Entities for composition, state, and cross-entity access
  • Events for the bus, delivery, and actions
  • Simulation for the fixed step, spawn and despawn, and snapshots

Released under the MIT License.