Engine
A game boots one engine. createGameEngine builds every part of it, GameEngine holds them, and the game manager runs the frame loop that drives the rest. Levels, entities, input, audio, and physics each have their own reference; this page covers the engine that owns them.
import { createGameEngine } from '@skewedaspect/sage';Booting an engine
createGameEngine(canvas, entityClasses, options) answers with a GameEngine that is wired and not yet running. canvas is what the render engine draws to; null boots headless. entityClasses is the entity registry every simulation this engine builds spawns from. See Entities.
createGameEngine is generic in the entity registry it is handed. GameEngine<Registry>, that engine's simulation, rebuildSimulation, and bindSimulation, its LevelManager, every LevelContext, and every Level carry the same registry, so a game that types its registry spawns through the engine, and inside its own levels, typed the way it spawns on a simulation it built directly. A registry handed in with no literal type of its own leaves spawn a string name over a plain state bag, on the engine as anywhere else.
A registry written with satisfies NodeEntityRegistry keeps its literal keys, so the engine's simulation types spawn against them:
import { NodeEntity, createGameEngine } from '@skewedaspect/sage';
import type { NodeEntityRegistry } from '@skewedaspect/sage';
class PlayerEntity extends NodeEntity
{
static override readonly behaviors = [] as const;
}
const entityClasses = { PlayerEntity } satisfies NodeEntityRegistry;
const engine = await createGameEngine(canvas, entityClasses, {});
const scene = engine.engines.sceneEngine.createScene();
engine.bindSimulation(engine.rebuildSimulation(scene, 'main'));
const simulation = engine.simulation;
if(simulation !== null)
{
const player = simulation.spawn('PlayerEntity');
}A name outside the registry, and a state field the class does not declare, are both compile errors.
A game passes its options in the third argument:
| Option | Default | Meaning |
|---|---|---|
renderOptions | { antialias: true, adaptToDeviceRatio: true, enableAllFeatures: true } | Babylon's own engine options, plus engine to force 'webgl' or 'webgpu' in place of the default 'auto'. enableAllFeatures requests every feature the WebGPU adapter supports; WebGL ignores it. webGPUSupportTimeout is how long, in seconds, boot waits to learn whether WebGPU is supported, 3 by default. A value that is not a positive, finite number is refused at boot with a RangeError. |
logLevel | 'debug' | The level every logger starts at. See Logging. |
audioChannels | none | The named channels a game wants audio on. Naming none opts out of audio entirely. |
largeWorldRendering | false | Babylon's large world rendering in the render engine, and Havok's multi-region physics in whichever levels enable physics |
debug | true | The debug console's namespace, or false for no console at all. true takes the default namespace, 'sage'. |
havokBinary | none | The Havok module's bytes, for a game booting outside a browser |
fixedStepDuration | 1 / 60 | Seconds per fixed step, for every simulation this engine builds that names no fixed-step duration of its own. See Simulation. |
Boot builds everything the engine owns: the render engine, the bus, the scene engine, the asset, level, save, game, and input managers, the collider debug manager, and the game timer. A game that named no audio channels has neither an AudioEngine nor an AudioManager, and a game whose debug option is false has no debug console. No scene and no simulation exists yet: the engine holds what builds one and builds it when a level, or the game itself, calls rebuildSimulation.
Havok is not loaded at boot. It loads when a scene first asks for physics, and a load that fails is not remembered, so the next ask starts a fresh one. See Physics.
renderOptions is typed RenderEngineOptions: Babylon's own options for whichever engine boots, plus an engine field of type EngineType, one of 'webgl', 'webgpu', or 'auto'. BabylonEngineOptions is the WebGL and WebGPU half of that union, antialias, adaptToDeviceRatio, and a nested options bag.
import type { RenderEngineOptions } from '@skewedaspect/sage';
export const renderOptions : RenderEngineOptions = {
engine: 'webgl',
antialias: true,
adaptToDeviceRatio: true,
};enableAllFeatures reaches only a WebGPU engine. To request a chosen set of features in its place, list them in deviceDescriptor.requiredFeatures: Babylon requests that list and ignores enableAllFeatures, so there is no need to turn it off first. An empty list requests no optional features, and so does enableAllFeatures: false with no list.
import type { RenderEngineOptions } from '@skewedaspect/sage';
export const renderOptions : RenderEngineOptions = {
engine: 'webgpu',
deviceDescriptor: {
requiredFeatures: [ 'texture-compression-bc', 'texture-compression-astc' ],
},
};Which render engine boots
A game booting headless passes no canvas and gets Babylon's NullEngine. Every manager, level, scene, and entity works on it and nothing draws, which is what a server that wants levels, geometry, or physics runs.
With a canvas, 'webgl' builds a WebGL engine and 'webgpu' builds a WebGPU one. 'auto', the default, prefers WebGPU and falls back to WebGL, deciding the fallback in two stages: boot first asks Babylon whether WebGPU is supported here, because a WebGPU engine claims the window's listeners while it is constructed and cannot give them back if initialization then fails, so an engine is never built just to find out. That question waits at most webGPUSupportTimeout seconds. An answer that has not come by then counts as unsupported, with a warning naming the wait that ran out, and an answer that arrives later changes nothing. A supported WebGPU engine that then fails to initialize warns and falls back to WebGL. Forcing 'webgpu' throws in both of those cases instead of falling back: a game that forced it asked for it.
webGPUSupportTimeout is in seconds, 3 by default, and is part of renderOptions:
import type { RenderEngineOptions } from '@skewedaspect/sage';
export const renderOptions : RenderEngineOptions = {
engine: 'auto',
webGPUSupportTimeout: 5,
};A WebGPU engine has no WebGL context, so a tool that reaches for one directly, a GPU timer query, a draw-call counter spliced into the GL draw entry points, finds nothing there and reports nothing, with no error from either Babylon or SAGE. A page that depends on such a tool forces 'webgl'.
The scene engine
SceneEngine, reached as engine.engines.sceneEngine, is the engine's one wrapper over Babylon's scene facilities. It is handed the render engine, a logging utility, and the Havok bytes a headless game supplied. A scene it creates is empty and belongs to whoever asked for it, which is a level. See Levels.
| Member | Meaning |
|---|---|
createScene() | A new, empty Babylon Scene |
enablePhysics(scene, gravity?, floatingOriginWorldRadius?) | Loads Havok on the first ask and reuses it on every ask after, two simultaneous asks included; builds this scene its own plugin over it. A load that fails is not remembered, so the next ask starts a fresh one. |
dispose() | Releases the Havok module and forgets a load of it still in flight. The scenes it created belong to their levels and are left alone. |
loadModel(path, scene) | Reads a file into a Babylon asset container without adding anything to the scene |
importMeshes(meshNames, path, scene) | Imports a file's contents straight into the scene, all of it or the meshes named |
createHemisphericLight / createDirectionalLight / createPointLight / createSpotLight / createRectAreaLight | One light of that kind, SAGE's defaults filled in |
createSphere / createBox / createGround / createCylinder | One primitive mesh, SAGE's defaults filled in |
loadModel and importMeshes each log what failed and rethrow.
Its light and mesh builders each build one Babylon object of that kind with SAGE's own defaults filled in; a caller's own option replaces a default, field by field. A game reaches Babylon directly for anything it does not build, and a camera is one of those: the game constructs its own and aims it itself. The scene engine attaches nothing to a canvas; which camera takes control is the level's decision.
| Builder | Field | Default |
|---|---|---|
createHemisphericLight | intensity | 0.7 |
createDirectionalLight | intensity | 1 |
createPointLight | intensity | 1 |
createSpotLight | intensity | 1 |
createRectAreaLight | intensity | 1 |
createSphere | diameter | 1 |
createSphere | segments | 32 |
createBox | size | 1 |
createGround | width | 6 |
createGround | height | 6 |
createGround | subdivisions | 2 |
createCylinder | height | 2 |
createCylinder | diameterTop | 1 |
createCylinder | diameterBottom | 1 |
enablePhysics defaults gravity to (0, -9.81, 0) and answers with a Promise, so a caller building a body against the scene awaits it first:
const scene = engine.engines.sceneEngine.createScene();
await engine.engines.sceneEngine.enablePhysics(scene);
const ground = engine.engines.sceneEngine.createGround('ground', { width: 20, height: 20 }, scene);The game manager
GameManager, reached as engine.managers.gameManager, owns the frame loop. It is handed the render engine, the bus, the level manager, and a logging utility, and it is given the game engine and the input manager once those exist, since both are built after it. It also resizes the render engine when the browser window resizes, while the game is started.
One rendered frame runs in this order:
- Unless the game is paused, the routing loop delivers the frame's captured input into the bound simulation, and then that simulation takes the frame's elapsed time and runs whatever whole fixed steps it covers. See Input and Simulation.
- Every frame callback runs, in registration order.
- The current level's scene renders.
A frame callback therefore runs after the frame's fixed steps, so timing a callback measures the callback alone: the steps' cost sits in the frame before it. A page moving a hand-rolled step-then-draw loop onto the frame loop, whose old timer wrapped the step and the draw together, gets a number that is not comparable to any reading it took before the move: it looks like an improvement and is a different measurement. Compare whole frames, or time the step and the callback separately on both sides.
A frame with no simulation bound runs neither the routing loop nor a step, and a frame with no current level renders nothing. The engine binds a NodeSimulation and nothing else, and a level is what builds one, so a game whose entities are in a rendered scene puts them in a level. A game with entities and no scene does not go through the engine: it builds a GameSimulation, steps it on its own clock, and drains its own bus, which is what sage-core on its own is for. Stepping a simulation or rendering a scene from a frame callback is not a supported way to use the engine. A throw out of an entity's update leaves the frame loop: it is a defect in a behavior, not news for a game to hear. See Simulation.
The fixed step drains the bus. On a frame where no step ran, because nothing is bound or the game is paused, the game manager drains once, where the step's own drain would have fallen, so a frame callback sees the same delivered events either way. Nothing drains before GameEngine.start(). See Events.
Frame callbacks
registerFrameCallback(callback, options) registers a function to run once per rendered frame, taking the frame's elapsed time in seconds. Any number of callbacks register, they run in registration order, and each registration answers with a function that removes that one callback; removing it a second time changes nothing. A callback that throws is logged and the frame goes on with the next one, because one game's per-frame work failing is not the frame loop's failure.
suspendedByPause is the one registration option, true unless a caller says otherwise: a game's own per-frame work stops with the simulation. A callback registered false keeps running against the frozen frame, reading the frame's own elapsed time unscaled, which is what presentation work wants.
The callback's own type is exported as FrameCallback, so a game that keeps its per-frame work somewhere other than the registration call types it there.
import type { FrameCallback } from '@skewedaspect/sage';
export const spinLogo : FrameCallback = (elapsed) =>
{
console.log(elapsed);
};const stop = engine.managers.gameManager.registerFrameCallback((elapsed) =>
{
console.log(`${ elapsed } seconds since the last frame`);
});
stop();Pause
pause(reason) suspends the routing loop, the fixed step, and every frame callback that did not opt out. Rendering continues, so the last frame stays on the screen. resume(reason) puts all three back. Each publishes game:paused or game:resumed carrying the reason it was given. Pausing a paused game and resuming a running one change nothing and publish nothing.
Input reaches nothing while the game is paused, so what resumes a paused game is a game's own UI or its own listener rather than an action.
engine.managers.gameManager.pause('menu-open');
engine.managers.gameManager.resume('menu-open');Time scale
GameManager.timeScale, 1 by default, multiplies the elapsed time the frame hands the simulation and every frame callback that answers to pause. A callback registered to run through pause reads the frame's own elapsed time unscaled, because presentation follows the wall clock and a game's own update follows game time.
Rendering is untouched and the fixed step's duration is untouched. A scale below 1 means fewer whole steps per frame and a scale above 1 means more, up to the dilation limit that caps any one call. A scale of zero advances the simulation by nothing without pausing it. A scale that is not a finite number of zero or more throws a RangeError, so NaN, Infinity, and a negative number are all refused. The debug console's sage.slow(factor) sets it, and refuses no argument the same way, since a console line is not compiled TypeScript; see Debug Console.
engine.managers.gameManager.timeScale = 0.5;Starting and stopping
A game calls GameEngine.start() and GameEngine.stop(), not the game manager's own; the engine drives those in turn. Starting an engine that has already started warns and does nothing further. Starting an engine that has stopped throws: an engine that has stopped does not start again. Stopping an engine that has already stopped warns and does nothing further.
start() runs the before-start hooks, starts the game manager's frame loop, then runs the start hooks, each awaited. stop() tears the engine down in one order:
- The frame loop stops, so nothing renders into what is about to be disposed.
- The teardown hooks run, awaited, in registration order; a hook that throws is logged and does not stop the rest.
- The debug console and the collider debug manager dispose.
- Every manager disposes.
- Every engine disposes.
A failure in any one of these does not stop the rest; the first error raised is raised again once everything else has disposed.
Lifecycle hooks
Four points take a hook: GameEngine.onBeforeStart, onStart, onTeardown, and the save manager's onBeforeSave. One policy covers all four, the policy registerFrameCallback already follows: each takes any number of functions, runs them in registration order, and answers with a function that removes the one just registered; removing it a second time changes nothing.
| Hook | Runs |
|---|---|
onBeforeStart(hook) | Before the frame loop starts, awaited |
onStart(hook) | After the frame loop has started, awaited |
onTeardown(hook) | Before anything is disposed, awaited; a hook that throws is logged and does not stop the teardown |
onBeforeSave(hook) | Before a save is written; its returned object is merged into the envelope's custom field. See Saves. |
const unregister = engine.onBeforeStart(async (booted) =>
{
console.log('about to start', booted.canvas !== null);
});
unregister();The asset manager
AssetManager, reached as engine.managers.assetManager, is the engine's one asset cache. It loads a file into a Babylon asset container, extracts one named mesh from a file with fragment syntax (models/props.glb#chest_lid), counts references per file, and disposes a file's container once the last reference is released. Two callers asking for the same file while it is still loading share the one load.
| Member | Meaning |
|---|---|
load(path) | The container, or the named mesh when the path carries a fragment. Counts one reference either way. |
instance(path) | A Babylon instance of a loaded fragment mesh, in the scene the manager read the file into |
clone(path, scene) | An independent copy of a loaded fragment mesh, built into the scene it is handed |
instantiate(path, scene, options?) | A copy of a whole file, built into the scene it is handed; instances: true instances meshes |
release(path) | Gives one reference back; the container and every fragment mesh taken from it dispose once the count reaches zero |
preload(paths) | Loads a batch, six at a time |
instance and clone both throw for a path nothing has loaded.
A mesh belongs to one scene, and a level draws only the meshes in its own, so clone takes the scene the copy is for. The copy is the named mesh by itself; a mesh parented under it inside the glb is not carried. A Babylon instance shares its source mesh's buffers and cannot leave that scene, so instance is for code working in the manager's own scene.
instantiate(path, scene) copies a whole file into the scene it is handed and answers with Babylon's InstantiatedEntries: the copy's rootNodes, skeletons, and animationGroups. The first call for a file in a scene reads the file into a container for that scene. Later calls for that file in that scene answer from the container, and calls made while the read is in flight share it. A read that fails rejects every call sharing it and is not remembered, so the next call for that file in that scene reads the file again. The container lives as long as its scene and is disposed with it, so it counts no references and release does not reach it. A file copied into two scenes is read once for each. Each answer is a fresh copy, with meshes cloned and materials shared with the container, and it carries none of the file's metadata. The caller disposes the copy. A path carrying a fragment is refused: the call rejects with an error naming the fragment, since a fragment is one mesh and clone copies one.
instantiate(path, scene, { instances: true }) makes each mesh in the copy that carries geometry and no skeleton a Babylon instance of the container's mesh in place of a clone. An empty mesh, such as a glTF file's own __root__, and a skinned mesh are cloned, since Babylon instances neither, and so is every transform node. An instance shares its container mesh's geometry and material, so it holds far less memory than a clone: on one hauler hull level of detail, 28.8 MB per clone against 0.14 MB per instance. Babylon draws every instance of one container mesh in a single call. Because the material is shared, a copy cannot take its own; per-copy colour needs Babylon's instanced buffers. The option is the exported type InstantiateOptions, { instances?: boolean }, and clones stay the default. The container cache, the metadata stripping, and the caller disposing the copy with entries.dispose() are the same as for clones.
preload costs about as long as its slowest asset rather than the sum of them all. Each asset that finishes publishes asset:progress naming the path, how many have finished, and the total; loads finish out of request order, so the paths do too. Every path is attempted and every load has settled before the call answers. A batch where everything loaded publishes asset:complete carrying the paths. A batch with any failure publishes nothing and throws an AggregateError naming every path that failed.
A level preloads whatever its configuration lists, before it creates its scene. See Levels.
await engine.managers.assetManager.preload([ 'models/crate.glb', 'models/barrel.glb' ]);
const chestLid = await engine.managers.assetManager.load('models/props.glb#chest_lid');
const instance = engine.managers.assetManager.instance('models/props.glb#chest_lid');const entries = await engine.managers.assetManager.instantiate('models/station.glb', scene);
for(const root of entries.rootNodes)
{
console.log(root.name);
}
entries.dispose();const hauler = await engine.managers.assetManager.instantiate('ships/hauler.glb', scene, { instances: true });
hauler.dispose();Logging
Everything in the engine logs through one logging utility, engine.logger, built at boot at the level logLevel names. getLogger(category) answers with that category's logger, the same instance every time it is asked, so every line SAGE writes carries the part of the engine that wrote it. Setting the level or the backend applies to every logger the utility has already handed out.
A logger writes at trace, debug, info, warn, and error, and pairs time with timeEnd around a measured span. A message below the current level is not written, and at level 'none' nothing is.
LogLevel is the type of the six level names, and LogLevels is the array of them in order, exported as a value so a settings screen can list the levels without repeating them. SAGELogger is the class the utility hands out, constructed with a category, a minimum level, and a backend; a game that wants a logger of its own outside the engine builds one directly.
import { LogLevels, SAGELogger } from '@skewedaspect/sage';
import type { LogLevel } from '@skewedaspect/sage';
const levels : readonly LogLevel[] = LogLevels;
const logger = new SAGELogger('Launcher', 'warn');
logger.warn(`writing at one of ${ levels.length } levels`);Two backends ship: ConsoleBackend, which writes to the browser console with the category, the time, and the level, and NullBackend, which discards everything. A game supplies its own by writing those same five levels and the timer pair, each taking the category as its first argument.
import type { LoggingBackend } from '@skewedaspect/sage';
const remoteBackend : LoggingBackend = {
trace() {},
debug() {},
info() {},
warn() {},
error(category, message, ...args) { console.error(category, message, ...args); },
time() {},
timeEnd() {},
};
engine.logger.setBackend(remoteBackend);
const logger = engine.logger.getLogger('MyGame');
logger.info('booted');A level names its own logger after itself, through the logging utility its context carries.
The game timer
GameTimer, reached as engine.timer, is the engine's source of game time: delay, interval, cooldown, and cancelAll, all in milliseconds of game time. It advances from a frame callback that answers to pause, so it stops while the game is paused and follows the time scale. See Timer for the full reference.
const cancel = engine.timer.delay(1000, () =>
{
console.log('a second of game time passed');
});Utilities
Vector and quaternion conversion
toVec3Object and toQuatObject take a Babylon Vector3 or Quaternion and answer with the plain { x, y, z } or { x, y, z, w } object a level's configuration and a save's state store. toVector3 and toQuaternion take one of those plain objects back and answer with the Babylon type the scene graph and Havok want. Each pair is a direct field copy in both directions; nothing about a value changes crossing it.
import { Vector3 } from '@babylonjs/core';
import { toVec3Object, toVector3 } from '@skewedaspect/sage';
const stored = toVec3Object(new Vector3(1, 2, 3));
const restored = toVector3(stored);The two plain shapes have names of their own: Vec3Config, which level configuration also uses (Levels), and QuatConfig, its { x, y, z, w } counterpart.
import { toQuaternion } from '@skewedaspect/sage';
import type { QuatConfig } from '@skewedaspect/sage';
const identity : QuatConfig = { x: 0, y: 0, z: 0, w: 1 };
const rotation = toQuaternion(identity);The canonical transform
getCanonicalTransform(node) answers with the position, rotation, and scaling that put a fresh node exactly where node sits in the scene, facing the way it faces, with a proper rotation and a positive scaling. Babylon's glTF loader hands every imported model a __root__ whose own transform mirrors one axis, folded into the world matrix of everything under it; decomposing such a matrix directly answers with a negative scaling and a rotation of the wrong handedness. getCanonicalTransform walks the node's ancestors and corrects for every mirrored one it finds, so a node built from what it answers with sits exactly where the marker does. A node with no mirrored ancestor above it answers with exactly what its own getWorldMatrix() decomposes to.
import type { TransformNode } from '@babylonjs/core';
import { getCanonicalTransform } from '@skewedaspect/sage';
declare const node : TransformNode;
const { position, rotation, scaling } = getCanonicalTransform(node);isBrowser
isBrowser() : boolean answers whether the code is running where window and window.document both exist. A headless boot answers false, and anything that only works against a real document -- an event listener, a piece of window state -- reads it first rather than throwing where there is no browser to reach. GameManager and InputManager both read it before touching window, and a game does the same:
import { isBrowser } from '@skewedaspect/sage';
if(isBrowser())
{
window.addEventListener('resize', () => { /* re-lay the HUD out */ });
}VERSION
VERSION is the version string this build of sage carries, taken from the package's own package.json at build time. A build that never defined it, a script or a test run outside a full build, answers with '0.0.0-dev' instead. It is what boot logs alongside starting and stopping the engine, and it is the engine version the save manager writes into every save envelope.
import { VERSION } from '@skewedaspect/sage';
console.log(`running SAGE ${ VERSION }`);The engine's own types
A game reaches most of these through the engine it was handed. Each is exported for the code that has to name one.
| Type | What it is |
|---|---|
Engines | The type of engine.engines: sceneEngine, and audioEngine for a game with audio channels |
DebugTools | The type of engine.debug: colliders, console, and expose |
GameHook<Registry> | What onBeforeStart, onStart, and onTeardown take, a function of the engine answering with a promise |
SimulationFactory<Registry> | What builds a NodeSimulation from a scene, a name, and an optional RebuildSimulationOptions (meshSource, fixedStepDuration); it is GameEngine.rebuildSimulation's own type |
