Skip to content

Hello Cube

This guide walks through the simplest SAGE application: a rotating cube rendered with BabylonJS, driven by SAGE's frame loop. It covers engine boot through SageCanvas, scene creation, frame callbacks, and pause and resume. It spawns no entities; the Entity Behaviors guide is where the entity system starts.

The full source is in examples/src/examples/hello-cube/.

Try it live

Run this example at Examples > Hello Cube.

Project structure

hello-cube/
├── index.vue          # Vue component: UI and engine wiring
└── level/
    └── setup.ts       # BabylonJS scene, camera, light, and cube

Step 1: The Vue component and SageCanvas

SageCanvas, from @skewedaspect/sage-vue, creates the canvas, calls createGameEngine, and emits engine-ready once the engine is up.

vue
<template>
    <SageCanvas @engine-ready="onEngineReady" :options="{ logLevel: 'info' }">
        <template #default="{ loading }">
            <div v-if="loading" class="loading-overlay">Loading...</div>
        </template>
    </SageCanvas>
</template>

The options prop is the SageOptions passed to createGameEngine. The default slot renders on top of the canvas and receives a loading flag. An :entity-classes prop carries the entity class table; this example has none.

The script section sets up the imports and state:

typescript
import { ref } from 'vue';
import type { GameEngine } from '@skewedaspect/sage';
import type { Mesh, Scene } from '@babylonjs/core';
import { SageCanvas } from '@skewedaspect/sage-vue';

import { setupLevel } from './level/setup.ts';

const ROTATION_SPEED_X = 0.3;
const ROTATION_SPEED_Y = 0.6;

const paused = ref(false);
let gameEngine : GameEngine | null = null;

gameEngine is a plain variable, not a Vue ref. Engine references are used in callbacks, never in templates, and keeping them out of Vue's reactivity avoids proxy overhead on every access.

Step 2: Setting up the level

Scene setup is isolated in its own file, which keeps the BabylonJS-specific code apart from the SAGE wiring.

typescript
import {
    ArcRotateCamera, Color3, Color4, HemisphericLight,
    type Mesh, MeshBuilder, type Scene, StandardMaterial, Vector3,
} from '@babylonjs/core';
import type { GameEngine } from '@skewedaspect/sage';

export interface LevelSetupResult
{
    scene : Scene;
    camera : ArcRotateCamera;
    cube : Mesh;
}

export function setupLevel(engine : GameEngine, canvas : HTMLCanvasElement) : LevelSetupResult
{
    const scene = engine.engines.sceneEngine.createScene();
    scene.clearColor = new Color4(0.12, 0.12, 0.14, 1);

    const camera = new ArcRotateCamera('camera', Math.PI / 4, Math.PI / 3, 5, Vector3.Zero(), scene);
    camera.attachControl(canvas, true);

    new HemisphericLight('light', new Vector3(0, 1, 0), scene).intensity = 0.8;

    const cube = MeshBuilder.CreateBox('cube', { size: 1.5 }, scene);

    const material = new StandardMaterial('cubeMaterial', scene);
    material.diffuseColor = new Color3(0.506, 0.173, 0.173);
    material.specularColor = new Color3(0.3, 0.3, 0.3);
    cube.material = material;

    return { scene, camera, cube };
}

The scene comes from engine.engines.sceneEngine.createScene().

Step 3: The frame callback

Back in the Vue component, onEngineReady ties everything together:

typescript
function onEngineReady(engine : GameEngine) : void
{
    gameEngine = engine;

    const level = setupLevel(engine, engine.canvas as HTMLCanvasElement);
    const scene = level.scene;
    const cube = level.cube;

    engine.managers.gameManager.registerFrameCallback((dt : number) =>
    {
        cube.rotation.x += ROTATION_SPEED_X * dt;
        cube.rotation.y += ROTATION_SPEED_Y * dt;

        scene.render();
    });

    engine.managers.gameManager.start();
}
  1. Set up the level: create the scene, camera, light, and cube.
  2. Register a frame callback: it runs once per rendered frame. dt is the seconds since the last frame; multiplying by it keeps the rotation speed independent of frame rate.
  3. Start the game manager: this begins the render loop. Without start(), nothing renders.

The callback calls scene.render() itself because this example creates its scene directly rather than through a level. With the level system, GameManager renders the current level's scene after the frame callbacks run.

Frame callbacks run after the simulation's fixed steps for the frame. This example has no simulation: no level has called rebuildSimulation, so engine.simulation is null and the fixed-step loop is skipped.

Step 4: Pause and resume

typescript
function togglePause() : void
{
    if(!gameEngine) { return; }

    if(paused.value)
    {
        gameEngine.managers.gameManager.resume();
    }
    else
    {
        gameEngine.managers.gameManager.pause();
    }

    paused.value = !paused.value;
}

While paused, the game manager skips the fixed steps and the frame callbacks. The render loop keeps running and renders the current level's scene, so the UI stays responsive. Here, with no level, pausing stops the callback that called scene.render(), so the last frame stays on screen.

Resource cleanup

When the component unmounts, SageCanvas stops the engine and disposes the BabylonJS engine, which takes the scene and all meshes with it.

What you learned

  • SageCanvas boots the engine and emits engine-ready
  • Scenes are created through engine.engines.sceneEngine.createScene()
  • gameManager.registerFrameCallback() runs once per rendered frame, after the fixed steps
  • gameManager.start() begins the loop; pause() and resume() control it

Next steps

Released under the MIT License.