Skip to content

Debug Console ​

DebugConsole installs a namespace on window, sage by default, and exposes values, dynamic getters, and callable commands onto it. registerBuiltins wires a GameEngine's commands onto it at boot. Open the browser's developer console, type sage.list(), and the running simulation's entities print as a table.

Configuration ​

The console is enabled by default and configured through SageOptions.debug:

ValueEffect
omittedEnabled as window.sage
{ namespace: 'myGame' }Enabled as window.myGame
falseDisabled. engine.debug.expose becomes a no-op.
typescript
const engine = await createGameEngine(canvas, entityClasses, { debug: { namespace: 'myGame' } });

Installing over an occupied window key throws, naming the key and pointing at the debug.namespace option. setNamespace() checks its new name before tearing the current namespace down, so a rejected rename leaves the console where it was.

The namespace is removed from window when the engine stops.

Where there is no window to install into -- a headless boot, a test, a server -- the console does nothing. Installing the namespace, exposing a value, moving to a new namespace, and disposing are all silent no-ops.

Built-in references ​

PropertyWhat it is
sage.engineThe GameEngine
sage.eventsThe engine's one bus, engine.bus
sage.physicsThe current level's scene's PhysicsEngine (scene.getPhysicsEngine()), or null
sage.collidersThe ColliderDebugManager
sage.levelThe current level. A dynamic getter; it follows level transitions.
sage.sceneThe current level's scene, or null
sage.entitiesThe entities view, indexed by ID

sage.physics and sage.scene are dynamic getters, so they follow the current level: physics belongs to a scene rather than to the game, and a level with no physics enabled leaves sage.physics null.

The entities view ​

sage.entities is a view onto the current simulation's entities, indexed by ID. sage.entities['entity-17'] answers with the GameEntity that ID names, the same way getEntity does. An ID nothing names logs a warning and answers undefined. The view carries no other property and wraps no manager.

An entity the view answers with is the entity itself, not a copy. Reading sage.entities['entity-17'].state reads its actual state bag, and sage.entities['entity-17'].ops.unlock('1234') calls its actual operation.

Built-in commands ​

Entities ​

CommandDoes
sage.list()Every entity in the simulation, as a console.table
sage.list('EnemyEntity')Every entity whose registry name matches
sage.list(undefined, 'hostile')Every entity carrying the tag
sage.list('EnemyEntity', 'hostile')Both filters together
sage.spawn('EnemyEntity', x, y, z)Spawns the class through the simulation with position in its initial state
sage.inspect(idOrEntity)Logs the entity's id, state, and node name and position
sage.tp(idOrEntity, x, y, z)Writes the entity's state.position

list prints one row per entity, with id, name, and node (the node's Babylon name, or (no node) for a GameEntity with none), plus displayName for an entity carrying NamedBehavior. It also returns the entities, so sage.list('EnemyEntity')[0] is the first match.

tp writes state.position for every entity with a node alike, and every such entity's position is computed from its node, so the write moves the node as it is made. See Entities.

Writing state straight from the console works the same way tp does internally:

javascript
sage.entities['entity-3'].state.position = { x: 0, y: 5, z: 0 }

See Writing state from the console for what a write like this means.

Events ​

CommandDoes
sage.logEvents('level:*')Logs every matching event on the engine's one bus. Returns an unsubscribe function.
sage.logEvents('door:*')A pattern works the same way for a game's own events
sage.stopLogging()Stops every active logger

logEvents requires a pattern -- sage.logEvents() with no argument throws, naming what it takes. It subscribes to engine.bus, the one bus an engine has, so it hears everything on it: level, asset, trigger, and game lifecycle events, a bound simulation's entity events, and actions, all under whatever pattern is given.

The loop ​

CommandDoes
sage.pause()Pauses the game manager: fixed steps and frame callbacks stop, rendering continues
sage.resume()Resumes it
sage.slow(factor)Sets the game manager's time scale

slow(factor) multiplies the elapsed time the frame hands the simulation and every frame callback that answers to pause: slow(0.25) runs the game at a quarter of wall clock, slow(4) at four times it, and slow(1) puts it back. The frame rate and the fixed step's duration are unchanged; the simulation runs correspondingly fewer or more whole steps per frame. A factor of zero stops the simulation advancing without pausing the game, unlike pause(), which also stops the routing loop and the frame callbacks. A factor that is not a finite number of zero or more throws, no argument at all included, since a console line is not compiled TypeScript.

Writing state from the console ​

A console line is plain JavaScript against the live object, so this runs:

javascript
sage.entities['entity-17'].state.health = 100

Inside a game's own source, an un-narrowed GameEntity's state is typed object and a named field is not reachable. That check belongs to the compiler, and there is no runtime guard behind it: nothing checks a write while the simulation runs.

A write like this notifies nobody: state is facts, and writing a field notifies nobody, whoever writes it. It is snapshot-visible, since a snapshot deep-copies whatever a state bag holds at the fixed-step boundary it is taken on, with nothing recorded about how a field came to hold what it holds. And it is replay-invisible: a replay runs fixed steps, and a console write is not part of one, so a replay from a snapshot taken before the write never meets it.

Operations remain the way a game's own logic changes another entity's state. A write straight to .state from the console reaches past that, on purpose.

Registering game-specific commands ​

engine.debug.expose(name, value) registers a value, a callable command, or a dynamic getter on the namespace:

typescript
engine.debug.expose('gameState', gameState);

engine.debug.expose('godmode', () =>
{
    const player = engine.simulation?.getEntity(playerID);

    if(player !== undefined && player.hasBehavior(HealthBehavior))
    {
        player.ops.setInvincible(true);
        console.log('[game] God mode enabled');
    }
});

engine.debug.expose('player', {
    get: () => engine.simulation?.getEntity(playerID),
});

A dynamic getter resolves on every access, so sage.player follows a respawn. The onStart hook, after levels and entities exist, is the natural place to register:

typescript
engine.onStart(async (ge) =>
{
    ge.debug.expose('reloadLevel', async () =>
    {
        await ge.managers.levelManager.transition('arena');
    });
});

A session ​

javascript
sage.list()                                          // what is in the scene
sage.list('DoorEntity')                              // the doors
sage.entities['entity-4'].state                      // one door's state bag
sage.entities['entity-4'].ops.unlock('1234')         // call its operation
sage.entities['entity-4'].showCollider({ color: '#ff0000' })    // see its collider
sage.slow(0.25)                                      // watch it slowly
sage.logEvents('level:*')                            // watch level events
sage.slow(1)
sage.stopLogging()
sage.entities['entity-4'].hideCollider()
  • Collider Debugging: per-entity and config-driven collider visualization
  • Events: the engine's one bus, and how logEvents and a game subscribe to it
  • Entities: state, operations, and cross-entity access

Released under the MIT License.