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:
| Value | Effect |
|---|---|
| omitted | Enabled as window.sage |
{ namespace: 'myGame' } | Enabled as window.myGame |
false | Disabled. engine.debug.expose becomes a no-op. |
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
| Property | What it is |
|---|---|
sage.engine | The GameEngine |
sage.events | The engine's one bus, engine.bus |
sage.physics | The current level's scene's PhysicsEngine (scene.getPhysicsEngine()), or null |
sage.colliders | The ColliderDebugManager |
sage.level | The current level. A dynamic getter; it follows level transitions. |
sage.scene | The current level's scene, or null |
sage.entities | The 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
| Command | Does |
|---|---|
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:
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
| Command | Does |
|---|---|
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
| Command | Does |
|---|---|
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:
sage.entities['entity-17'].state.health = 100Inside 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:
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:
engine.onStart(async (ge) =>
{
ge.debug.expose('reloadLevel', async () =>
{
await ge.managers.levelManager.transition('arena');
});
});A session
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()Related pages
- Collider Debugging: per-entity and config-driven collider visualization
- Events: the engine's one bus, and how
logEventsand a game subscribe to it - Entities: state, operations, and cross-entity access
