Skip to content

Vue 3 Integration

The @skewedaspect/sage-vue package provides a canvas wrapper component and a set of Vue 3 composables for building reactive game UI on top of the SAGE engine. Entity state, input actions, events, and timers are all exposed as Vue refs with automatic lifecycle cleanup.

Installation

bash
npm install @skewedaspect/sage-vue

@skewedaspect/sage-vue has peer dependencies on vue (3.x) and @skewedaspect/sage.

SageCanvas

SageCanvas is the root component that creates (or accepts) a SAGE engine instance and renders the WebGL canvas. All composables described below must be used inside a component that is a descendant of <SageCanvas>.

Props

PropTypeDefaultDescription
entityDefinitionsGameEntityDefinition[][]Entity definitions to register with the engine on creation.
optionsSageOptions{}Engine configuration passed to createGameEngine().
engineGameEngine---Optional pre-created engine instance. When provided, the component will not create or dispose the engine itself.

Events

EventPayloadDescription
engine-readyGameEngineFired once the engine has initialized and is ready to use.
engine-errorunknownFired if engine creation fails.

Slot Props

The default slot receives scoped props for rendering loading and error states:

Slot PropTypeDescription
loadingbooleantrue until the engine finishes initializing.
errorunknownThe error object if initialization failed, otherwise null.
engineGameEngine | nullThe engine instance once ready.

Basic Usage

vue
<template>
    <SageCanvas
        :entity-definitions="definitions"
        :options="engineOptions"
        @engine-ready="onReady"
    >
        <template #default="{ loading, error }">
            <div v-if="loading" class="loading">Initializing engine...</div>
            <div v-else-if="error" class="error">Failed to start: {{ error }}</div>
            <GameHud v-else />
        </template>
    </SageCanvas>
</template>

<!--------------------------------------------------------------------------------------------------------------------->

<style lang="scss" scoped>
    .loading, .error {
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        color: white;
        font-size: 1.5rem;
    }
</style>

<!--------------------------------------------------------------------------------------------------------------------->

<script setup lang="ts">
    import { SageCanvas } from '@skewedaspect/sage-vue';
    import type { GameEngine, GameEntityDefinition, SageOptions } from '@skewedaspect/sage';

    // Components
    import GameHud from './gameHud.vue';

    const definitions : GameEntityDefinition[] = [
        // ... your entity definitions
    ];

    const engineOptions : SageOptions = {
        // ... your engine options
    };

    function onReady(engine : GameEngine) : void
    {
        engine.start();
    }
</script>

<!--------------------------------------------------------------------------------------------------------------------->

External Engine

If you need to create the engine yourself (for example, to configure it before mounting or to share it across multiple components), pass a pre-created GameEngine instance via the engine prop. When you do this, SageCanvas will not create or dispose the engine -- you own its lifecycle.

vue
<template>
    <SageCanvas :engine="engine" @engine-ready="onReady">
        <template #default="{ loading }">
            <div v-if="loading">Attaching engine...</div>
            <GameHud v-else />
        </template>
    </SageCanvas>
</template>

<!--------------------------------------------------------------------------------------------------------------------->

<script setup lang="ts">
    import { SageCanvas } from '@skewedaspect/sage-vue';
    import { createGameEngine } from '@skewedaspect/sage';
    import type { GameEngine } from '@skewedaspect/sage';

    // Components
    import GameHud from './gameHud.vue';

    // Engine created externally -- you control its lifecycle
    const canvas = document.getElementById('my-canvas') as HTMLCanvasElement;
    const engine = await createGameEngine(canvas, [], { logLevel: 'debug' });

    function onReady(eng : GameEngine) : void
    {
        eng.start();
    }
</script>

<!--------------------------------------------------------------------------------------------------------------------->

WARNING

When using the engine prop, SageCanvas will not call stop() or dispose() on unmount. You are responsible for cleaning up the engine when it is no longer needed.

Overlay Behavior

SageCanvas renders a .sage-overlay div on top of the canvas that holds slot content. The overlay itself has pointer-events: none so clicks pass through to the canvas, but all child elements inside the overlay automatically get pointer-events: auto restored. This means your HUD buttons, menus, and other UI elements are interactive by default without any extra CSS.

useSageEngine

Returns the raw engine reference and a computed event bus. This is the foundation all other composables build on.

typescript
import { useSageEngine } from '@skewedaspect/sage-vue';

const { engine, eventBus } = useSageEngine();

// engine is ShallowRef<GameEngine | null>
// eventBus is ComputedRef<GameEventBus | null>

The engine ref starts as null and is populated once the canvas mounts and the engine initializes.

Signature:

typescript
function useSageEngine() : {
    engine : ShallowRef<GameEngine | null>;
    eventBus : ComputedRef<GameEventBus | null>;
}

WARNING

Throws an error if called outside of a <SageCanvas> component tree. Always ensure your component is a descendant of <SageCanvas>.

useSageEvent

Subscribes to engine events by pattern with automatic cleanup on unmount.

typescript
import { useSageEvent } from '@skewedaspect/sage-vue';

// Listen for all entity events using a wildcard
useSageEvent('entity:*', (event) =>
{
    console.log(`Entity event: ${ event.type }`, event.payload);
});

// RegExp patterns work too
useSageEvent(/^player:/, (event) =>
{
    handlePlayerEvent(event);
});

The subscription is created immediately if the engine is available, or deferred via a watch until it becomes available. When the engine ref changes (e.g., hot reload), the old subscription is torn down and a new one is created. The subscription is automatically removed when the component unmounts.

Signature:

typescript
function useSageEvent(pattern : string | RegExp, handler : EventHandler) : void

useSageAction

Returns a reactive ref bound to an input action's current value. The ref updates automatically whenever the engine publishes an action:<name> event.

typescript
import { useSageAction } from '@skewedaspect/sage-vue';

// Track whether the "jump" action is active
const jump = useSageAction('jump');

// Track an analog stick axis
const moveX = useSageAction('moveX');

The ref is Ref<boolean | number> -- boolean for button-style actions (pressed/released), number for axis-style actions (analog stick deflection, trigger pressure).

Signature:

typescript
function useSageAction(actionName : string) : Ref<boolean | number>

Template Example: Action Indicator

vue
<template>
    <div class="action-display">
        <span :class="{ active: jump }">Jump: {{ jump }}</span>
        <span>Move X: {{ moveX.toFixed(2) }}</span>
    </div>
</template>

<!--------------------------------------------------------------------------------------------------------------------->

<style lang="scss" scoped>
    .action-display {
        position: absolute;
        top: 10px;
        right: 10px;
        display: flex;
        flex-direction: column;
        gap: 4px;
        color: white;
        font-family: monospace;
    }

    .active {
        color: #2ecc71;
        font-weight: bold;
    }
</style>

<!--------------------------------------------------------------------------------------------------------------------->

<script setup lang="ts">
    import { useSageAction } from '@skewedaspect/sage-vue';

    const jump = useSageAction('jump');
    const moveX = useSageAction('moveX');
</script>

<!--------------------------------------------------------------------------------------------------------------------->

useSageState

Returns a reactive ref synced to an entity's state, updated via entity:state-changed events. Optionally tracks a single property instead of the entire state object.

typescript
import { useSageState } from '@skewedaspect/sage-vue';

// Track entire entity state
const playerState = useSageState<PlayerState>('player-entity-id');

// Track a single property
const hp = useSageState<number>('player-entity-id', 'hp');

The composable reads the initial state from the entity (if it exists at call time) and then listens for entity:state-changed events to keep the ref in sync. Only events matching the specified entityId trigger updates. Cleanup is automatic on unmount.

Signature:

typescript
function useSageState<T = unknown>(entityId : string, key ?: string) : Ref<T>

Template Example: Game HUD

vue
<template>
    <div class="hud">
        <div class="health-bar" :style="{ width: `${ hp }%` }">
            {{ hp }} HP
        </div>
        <div class="score">Score: {{ playerState.score }}</div>
    </div>
</template>

<!--------------------------------------------------------------------------------------------------------------------->

<style lang="scss" scoped>
    .hud {
        position: absolute;
        top: 10px;
        left: 10px;
    }

    .health-bar {
        background: #e74c3c;
        color: white;
        padding: 4px 8px;
        transition: width 0.3s;
    }
</style>

<!--------------------------------------------------------------------------------------------------------------------->

<script setup lang="ts">
    import { useSageState } from '@skewedaspect/sage-vue';

    interface PlayerState
    {
        hp : number;
        score : number;
    }

    const hp = useSageState<number>('player-entity-id', 'hp');
    const playerState = useSageState<PlayerState>('player-entity-id');
</script>

<!--------------------------------------------------------------------------------------------------------------------->

useSageTimer

Wraps the engine's GameTimer with automatic lifecycle management. All active delays and intervals created through this composable are cancelled when the component unmounts.

typescript
import { useSageTimer } from '@skewedaspect/sage-vue';

const { delay, interval, cooldown } = useSageTimer();

// One-shot delay -- cancelled automatically on unmount
delay(3000, () =>
{
    showTutorialHint();
});

// Repeating interval -- also auto-cancelled
const cancelRegen = interval(1000, () =>
{
    regenerateHealth();
});

// Manual cancel is still available
cancelRegen();

// Cooldowns are passive pollable objects
const attackCooldown = cooldown(500);
if(attackCooldown.ready)
{
    attack();
    attackCooldown.reset();
}

If the engine is not yet available when a timer function is called, delay and interval return no-op cancel functions and cooldown returns a handle that is permanently not-ready. These are safe to call unconditionally.

Signature:

typescript
function useSageTimer() : {
    delay : (ms : number, callback : () => void) => () => void;
    interval : (ms : number, callback : () => void) => () => void;
    cooldown : (ms : number) => CooldownHandle;
}

Template Example: Ability Bar with Cooldowns

vue
<template>
    <div class="ability-bar">
        <button
            :disabled="!canFireball"
            @click="castFireball"
        >
            Fireball
        </button>
    </div>
</template>

<!--------------------------------------------------------------------------------------------------------------------->

<style lang="scss" scoped>
    .ability-bar {
        position: absolute;
        bottom: 10px;
        left: 50%;
        transform: translateX(-50%);
    }
</style>

<!--------------------------------------------------------------------------------------------------------------------->

<script setup lang="ts">
    import { ref } from 'vue';
    import { useSageTimer, useSageEngine } from '@skewedaspect/sage-vue';

    const { engine } = useSageEngine();
    const { cooldown, delay } = useSageTimer();
    const fireballCooldown = cooldown(2000);

    const canFireball = ref(true);

    function castFireball() : void
    {
        if(!fireballCooldown.ready) { return; }

        engine.value?.eventBus.publish({ type: 'spell:fireball' });
        fireballCooldown.reset();
        canFireball.value = false;

        // Re-enable the button after the cooldown period
        delay(2000, () => { canFireball.value = true; });
    }
</script>

<!--------------------------------------------------------------------------------------------------------------------->

Quick Reference

ComposableReturnsAuto-cleanup
useSageEngine(){ engine, eventBus }No (refs only)
useSageEvent(pattern, handler)voidYes -- unsubscribes on unmount
useSageAction(actionName)Ref<boolean | number>Yes -- unsubscribes on unmount
useSageState(entityId, key?)Ref<T>Yes -- unsubscribes on unmount
useSageTimer(){ delay, interval, cooldown }Yes -- cancels delays and intervals on unmount

TIP

All composables except useSageEngine() handle the "engine not ready yet" case gracefully. You can call them in setup() without waiting for the engine-ready event -- they will wire up their subscriptions as soon as the engine becomes available.

Released under the MIT License.