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, GameSimulationView, GameSimulation, 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, |
| GameSimulation, 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, 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 owns the entity list, the event bus, the entity registry, the tick counter, and the accumulator, and it implements GameSimulationView. Entities and behaviors never reach a manager. An entity is created with a GameSimulationView and holds nothing else from outside, and a behavior sees this.entity and this.simulation.
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 registry the simulation is constructed with:
import { createGameEngine } from '@skewedaspect/sage';
const entityClasses = { PlayerEntity, EnemyEntity, CollectibleEntity } satisfies NodeEntityRegistry;
const engine = await createGameEngine(canvas, entityClasses, { logLevel: 'info' });A NodeEntity's mesh comes from a mesh source, handed to engine.rebuildSimulation(scene, name, { 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:
const simulation = engine.simulation;
if(simulation !== null)
{
const player = simulation.spawn('PlayerEntity', { 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, so a callback that times itself measures neither the steps nor 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()One bus per engine
An engine has one bus, GameEngine.bus, built by createGameEngine and held in sage-core's EventBus class. Entities register on it, behaviors subscribe through static events, and every emission queues until the drain. Its vocabulary is closed: a game declares its own event types by augmenting EventMap, and sage declares its own library events -- level, asset, trigger, and game lifecycle events, and input:deviceChanged -- into the same map, so they are typed and delivered like anything else. See Events.
A simulation attaches to the engine bus while it is the one the engine is bound to, forwarding its entities' registrations and emissions to it; a simulation built with no engine, on a server, holds its own bus instead. Anything holding the engine, not only an entity, may subscribe with engine.bus.on(subscription, callback) and emit with engine.bus.emit(event).
Determinism
SAGE's fixed step and its bus call 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.
The save manager is the one exception: it timestamps a save envelope with Date.now() by default. That timestamp lives in the envelope, not in the snapshot a replay runs, so it never enters the deterministic path.
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
