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 registry; 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';

// setupLevel comes from ./level/setup.ts, which Step 2 covers.

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, so nothing about the engine needs to be reactive.

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 { SceneEngine } from '@skewedaspect/sage';

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

export function setupLevel(sceneEngine : SceneEngine) : LevelSetupResult
{
    const scene = 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(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 sceneEngine.createScene(). setupLevel takes the scene engine alone, not the whole GameEngine, since building a scene is all it does here.

Step 3: The frame callback ​

Back in the Vue component, onEngineReady ties everything together:

typescript
async function onEngineReady(engine : GameEngine) : Promise<void>
{
    gameEngine = engine;

    const level = setupLevel(engine.engines.sceneEngine);
    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();
    });

    await engine.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 engine: engine.start() runs the before-start and start lifecycle hooks around starting the game manager's render loop. Without it, 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
  • engine.start() runs the lifecycle hooks and begins the loop; gameManager.pause() and resume() control it

Next steps ​

Released under the MIT License.