Guide

Guide Recipes Build Orb Dodge

Build Orb Dodge

Build a complete small game from scratch: player movement, spawning orbs, collision, scoring, a HUD, and a game-over screen — all without external assets.

Intermediate ~4 min read

What you'll learn

  • wire a complete game from scenes, input, and graphics
  • spawn, move, and collide objects each frame
  • transition to a game-over scene and restart

Before you start

Build Orb Dodge

This chapter builds a small, complete game end-to-end. You will see how several ExoJS concepts fit together in one project: a scene that runs every frame, keyboard input, Graphics primitives for all visuals, a HUD with live score and time, and a separate game-over scene that transitions back into the game on restart.

No external assets are needed. Every shape is drawn in code.

The finished result is playable in the playground at the bottom of this page.

What you need

Start from a create-exo-app project using the game-starter or minimal template:

npm create exo-app@latest my-orb-dodge

If you already have a project, the only package you need is @codexo/exojs.

Application setup

Every ExoJS project starts with an Application. For a fixed-size game canvas:

examples/showcase/orb-dodge.ts
const app = new Application({
    canvas: {
        width: CANVAS_WIDTH,
        height: CANVAS_HEIGHT,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(10, 14, 26),
});

clearColor sets the background that context.backend.clear() paints at the start of each frame. A dark near-black makes the colored orbs stand out clearly.

Scene structure

The game uses two scenes: PlayScene drives the active game loop, GameOverScene shows the result and offers a restart.

Define a constant set for canvas dimensions and game parameters at the top so both scenes can reference them:

const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 600;
const PLAYER_RADIUS = 18;
const PLAYER_SPEED = 260;
const ORB_RADIUS = 14;
const SPAWN_INTERVAL = 1.2;

A scene has four lifecycle hooks — override the ones you need:

  • init() — set up state; called once per activation
  • update(delta) — per-frame logic; delta.seconds carries elapsed time
  • draw(context) — per-frame rendering; start with context.backend.clear()

Player and keyboard movement

The player is a filled circle drawn with Graphics. Keyboard movement uses this.inputs.onActive (fires every frame while the key is held) and this.inputs.onStop (fires when the key is released) to track a directional velocity:

init() {
    this._world = new Container();

    this._player = new Graphics();
    this._player.fillColor = new Color(80, 160, 255);
    this._player.drawCircle(0, 0, PLAYER_RADIUS);
    this._player.setPosition(this._px, this._py);
    this._world.addChild(this._player);

    this.inputs.onActive(Keyboard.W, () => { this._dy = -1; });
    this.inputs.onStop(Keyboard.W,  () => { if (this._dy < 0) this._dy = 0; });
    this.inputs.onActive(Keyboard.Up, () => { this._dy = -1; });
    this.inputs.onStop(Keyboard.Up,  () => { if (this._dy < 0) this._dy = 0; });
    // ... A/D and Left/Right similarly
}

In update, normalize the direction vector so diagonal movement is not faster than cardinal movement, then clamp the position inside the canvas:

update(delta) {
    const mag = Math.hypot(this._dx, this._dy) || 1;
    if (this._dx !== 0 || this._dy !== 0) {
        this._px += (this._dx / mag) * PLAYER_SPEED * delta.seconds;
        this._py += (this._dy / mag) * PLAYER_SPEED * delta.seconds;
    }
    this._px = Math.max(PLAYER_RADIUS, Math.min(CANVAS_WIDTH - PLAYER_RADIUS, this._px));
    this._py = Math.max(PLAYER_RADIUS, Math.min(CANVAS_HEIGHT - PLAYER_RADIUS, this._py));
    this._player.setPosition(this._px, this._py);
}

this.inputs is scene-bound: all bindings registered through it are automatically released when the scene is destroyed. No manual cleanup is needed.

Spawning orbs

Orbs are created dynamically during update on a timer. Each orb spawns just outside one of the four edges and moves toward the center area with a small random offset:

_spawnOrb() {
    const danger = Math.random() < 0.4;
    // pick a random edge (0=top, 1=right, 2=bottom, 3=left)
    const side = Math.floor(Math.random() * 4);
    let ox, oy;
    switch (side) {
        case 0: ox = Math.random() * CANVAS_WIDTH; oy = -ORB_RADIUS;              break;
        case 1: ox = CANVAS_WIDTH + ORB_RADIUS;    oy = Math.random() * CANVAS_HEIGHT; break;
        case 2: ox = Math.random() * CANVAS_WIDTH; oy = CANVAS_HEIGHT + ORB_RADIUS; break;
        default: ox = -ORB_RADIUS;                 oy = Math.random() * CANVAS_HEIGHT; break;
    }
    // aim at a random point near center
    const tx = CANVAS_WIDTH / 2  + (Math.random() - 0.5) * 300;
    const ty = CANVAS_HEIGHT / 2 + (Math.random() - 0.5) * 300;
    const dist = Math.hypot(tx - ox, ty - oy) || 1;
    const speed = 80 + Math.random() * 120;

    const gfx = new Graphics();
    gfx.fillColor = danger ? new Color(255, 80, 80) : new Color(80, 220, 120);
    gfx.drawCircle(0, 0, ORB_RADIUS);
    gfx.setPosition(ox, oy);
    this._world.addChild(gfx);
    this._orbs.push({ gfx, vx: ((tx - ox) / dist) * speed, vy: ((ty - oy) / dist) * speed, danger });
}

Orbs are stored in an array of { gfx, vx, vy, danger } objects. Danger orbs are red; collectible orbs are green.

Collision and scoring

Each frame, move every orb and check its distance to the player. Distance collision is reliable for circles because it captures overlap without axis-aligned assumptions:

const dist = Math.hypot(ox - this._px, oy - this._py);
if (dist < PLAYER_RADIUS + ORB_RADIUS) {
    this._world.removeChild(orb.gfx);
    orb.gfx.destroy();
    if (orb.danger) {
        // clean up remaining orbs, switch to game over
        for (const o of survived) {
            this._world.removeChild(o.gfx);
            o.gfx.destroy();
        }
        gameEnded = true;
        continue;
    }
    this._score++;
    this._scoreText.text = `Score: ${this._score}`;
    continue;
}

Orbs that leave the canvas are also removed. After the loop, if gameEnded is true, call setScene to switch to the game-over screen. Use a flag instead of breaking early so all remaining orbs are cleaned up in the same loop pass.

HUD text

Score and elapsed time sit on top of the world as plain Text nodes — rendered after context.render(this._world) so they always appear in front:

draw(context) {
    context.backend.clear();
    context.render(this._world);
    context.render(this._scoreText);
    context.render(this._timeText);
}

Text content is updated by assigning to .text:

this._scoreText.text = `Score: ${this._score}`;
this._timeText.text  = `${this._elapsed.toFixed(1)} s`;

Game-over scene and restart

GameOverScene receives the final score and time via a setResult method called just before setScene. This decouples data transfer from scene initialization — init always reads from _finalScore / _finalTime, whatever they were set to:

examples/showcase/orb-dodge.ts — GameOverScene
class GameOverScene extends Scene {
    private title!: Text;
    private stats!: Text;
    private hint!: Text;
    private finalScore = 0;
    private finalTime = 0;

    setResult(score: number, time: number): void {
        this.finalScore = score;
        this.finalTime = time;
    }

    override init(): void {
        this.title = new Text('GAME OVER', {
            align: 'center',
            fillColor: new Color(255, 80, 80),
            fontSize: 52,
            fontWeight: 'bold',
        });
        this.title.setAnchor(0.5);
        this.title.setPosition(CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 - 70);

        this.stats = new Text(`Score: ${this.finalScore}   Time: ${this.finalTime.toFixed(1)} s`, {
            align: 'center',
            fillColor: Color.white,
            fontSize: 26,
        });
        this.stats.setAnchor(0.5);
        this.stats.setPosition(CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2);

        this.hint = new Text('Press Space or R to play again', {
            align: 'center',
            fillColor: new Color(160, 160, 160),
            fontSize: 18,
        });
        this.hint.setAnchor(0.5);
        this.hint.setPosition(CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 + 70);

        const restart = (): void => {
            void this.app.scene.setScene(play);
        };
        this.inputs.onTrigger(Keyboard.Space, restart);
        this.inputs.onTrigger(Keyboard.R, restart);
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.title);
        context.render(this.stats);
        context.render(this.hint);
    }
}

When the player restarts, setScene(play) switches back to PlayScene. SceneManager calls init() on play again — the scene reinitializes from scratch, so no manual reset is needed. Input bindings registered in init via this.inputs are disposed and recreated automatically with each scene activation.

How scenes reference each other

Both scenes reference the same two instances at module level:

const play     = new PlayScene();
const gameOver = new GameOverScene();

app.start(play);

PlayScene calls gameOver.setResult(...) then void this.app.scene.setScene(gameOver). GameOverScene calls void this.app.scene.setScene(play). The forward reference is safe because both scenes exist before app.start is called.

Cleanup in destroy

When SceneManager switches scenes it calls destroy() on the outgoing scene. For PlayScene, override it to release orb graphics that were created dynamically:

override destroy() {
    for (const orb of this._orbs) {
        orb.gfx.destroy();
    }
    this._world?.destroy();
    super.destroy();
}

Static nodes created in init are recreated on every activation, so they are always fresh when init is called again.

Complete example

The full, playable game is in the playground. Open it to read the complete source, run it, or fork it as a starting point:

Orb Dodge Keyboard Open in Playground View source
import { Application, Color, Container, Graphics, Keyboard, Scene, Text } from '@codexo/exojs';

const CANVAS_WIDTH = 1280;
const CANVAS_HEIGHT = 720;
const PLAYER_RADIUS = 18;
const PLAYER_SPEED = 260;
const ORB_RADIUS = 14;
const SPAWN_INTERVAL = 0.9;
const ORB_SPEED_MIN = 80;
const ORB_SPEED_MAX = 200;

// #region guide:application-setup
const app = new Application({
    canvas: {
        width: CANVAS_WIDTH,
        height: CANVAS_HEIGHT,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: new Color(10, 14, 26),
});
// #endregion guide:application-setup

interface OrbData {
    gfx: Graphics;
    vx: number;
    vy: number;
    danger: boolean;
}

class PlayScene extends Scene {
    private world!: Container;
    private player!: Graphics;
    private orbs: OrbData[] = [];
    private px = CANVAS_WIDTH / 2;
    private py = CANVAS_HEIGHT / 2;
    private dx = 0;
    private dy = 0;
    private score = 0;
    private elapsed = 0;
    private spawnTimer = 0;
    private scoreText!: Text;
    private timeText!: Text;

    override init(): void {
        this.px = CANVAS_WIDTH / 2;
        this.py = CANVAS_HEIGHT / 2;
        this.score = 0;
        this.elapsed = 0;
        this.spawnTimer = 0;
        this.dx = 0;
        this.dy = 0;
        this.orbs = [];

        this.world = new Container();

        this.player = new Graphics();
        this.player.fillColor = new Color(80, 160, 255);
        this.player.drawCircle(0, 0, PLAYER_RADIUS);
        this.player.setPosition(this.px, this.py);
        this.world.addChild(this.player);

        this.scoreText = new Text('Score: 0', { fillColor: Color.white, fontSize: 20 });
        this.scoreText.setPosition(16, 14);

        this.timeText = new Text('0.0 s', { fillColor: Color.white, fontSize: 20 });
        this.timeText.setPosition(CANVAS_WIDTH - 90, 14);

        this.inputs.onActive(Keyboard.W, () => { this.dy = -1; });
        this.inputs.onStop(Keyboard.W, () => { if (this.dy < 0) this.dy = 0; });
        this.inputs.onActive(Keyboard.Up, () => { this.dy = -1; });
        this.inputs.onStop(Keyboard.Up, () => { if (this.dy < 0) this.dy = 0; });

        this.inputs.onActive(Keyboard.S, () => { this.dy = 1; });
        this.inputs.onStop(Keyboard.S, () => { if (this.dy > 0) this.dy = 0; });
        this.inputs.onActive(Keyboard.Down, () => { this.dy = 1; });
        this.inputs.onStop(Keyboard.Down, () => { if (this.dy > 0) this.dy = 0; });

        this.inputs.onActive(Keyboard.A, () => { this.dx = -1; });
        this.inputs.onStop(Keyboard.A, () => { if (this.dx < 0) this.dx = 0; });
        this.inputs.onActive(Keyboard.Left, () => { this.dx = -1; });
        this.inputs.onStop(Keyboard.Left, () => { if (this.dx < 0) this.dx = 0; });

        this.inputs.onActive(Keyboard.D, () => { this.dx = 1; });
        this.inputs.onStop(Keyboard.D, () => { if (this.dx > 0) this.dx = 0; });
        this.inputs.onActive(Keyboard.Right, () => { this.dx = 1; });
        this.inputs.onStop(Keyboard.Right, () => { if (this.dx > 0) this.dx = 0; });
    }

    private spawnOrb(): void {
        const danger = Math.random() < 0.4;
        const side = Math.floor(Math.random() * 4);
        let ox: number;
        let oy: number;
        switch (side) {
            case 0: ox = Math.random() * CANVAS_WIDTH; oy = -ORB_RADIUS; break;
            case 1: ox = CANVAS_WIDTH + ORB_RADIUS; oy = Math.random() * CANVAS_HEIGHT; break;
            case 2: ox = Math.random() * CANVAS_WIDTH; oy = CANVAS_HEIGHT + ORB_RADIUS; break;
            default: ox = -ORB_RADIUS; oy = Math.random() * CANVAS_HEIGHT; break;
        }
        const tx = CANVAS_WIDTH / 2 + (Math.random() - 0.5) * (CANVAS_WIDTH * 0.6);
        const ty = CANVAS_HEIGHT / 2 + (Math.random() - 0.5) * (CANVAS_HEIGHT * 0.6);
        const dist = Math.hypot(tx - ox, ty - oy) || 1;
        const speed = ORB_SPEED_MIN + Math.random() * (ORB_SPEED_MAX - ORB_SPEED_MIN);

        const gfx = new Graphics();
        gfx.fillColor = danger ? new Color(255, 80, 80) : new Color(80, 220, 120);
        gfx.drawCircle(0, 0, ORB_RADIUS);
        gfx.setPosition(ox, oy);
        this.world.addChild(gfx);
        this.orbs.push({ gfx, vx: ((tx - ox) / dist) * speed, vy: ((ty - oy) / dist) * speed, danger });
    }

    override update(delta): void {
        this.elapsed += delta.seconds;
        this.spawnTimer += delta.seconds;

        if (this.spawnTimer >= SPAWN_INTERVAL) {
            this.spawnTimer -= SPAWN_INTERVAL;
            this.spawnOrb();
        }

        const mag = Math.hypot(this.dx, this.dy) || 1;
        if (this.dx !== 0 || this.dy !== 0) {
            this.px += (this.dx / mag) * PLAYER_SPEED * delta.seconds;
            this.py += (this.dy / mag) * PLAYER_SPEED * delta.seconds;
        }
        this.px = Math.max(PLAYER_RADIUS, Math.min(CANVAS_WIDTH - PLAYER_RADIUS, this.px));
        this.py = Math.max(PLAYER_RADIUS, Math.min(CANVAS_HEIGHT - PLAYER_RADIUS, this.py));
        this.player.setPosition(this.px, this.py);

        let gameEnded = false;
        const survived: OrbData[] = [];

        for (const orb of this.orbs) {
            orb.gfx.move(orb.vx * delta.seconds, orb.vy * delta.seconds);

            if (gameEnded) {
                this.world.removeChild(orb.gfx);
                orb.gfx.destroy();
                continue;
            }

            const ox = orb.gfx.x;
            const oy = orb.gfx.y;

            if (ox < -80 || ox > CANVAS_WIDTH + 80 || oy < -80 || oy > CANVAS_HEIGHT + 80) {
                this.world.removeChild(orb.gfx);
                orb.gfx.destroy();
                continue;
            }

            const dist = Math.hypot(ox - this.px, oy - this.py);
            if (dist < PLAYER_RADIUS + ORB_RADIUS) {
                this.world.removeChild(orb.gfx);
                orb.gfx.destroy();
                if (orb.danger) {
                    for (const o of survived) {
                        this.world.removeChild(o.gfx);
                        o.gfx.destroy();
                    }
                    gameEnded = true;
                    continue;
                }
                this.score++;
                this.scoreText.text = `Score: ${this.score}`;
                continue;
            }

            survived.push(orb);
        }

        this.orbs = gameEnded ? [] : survived;

        if (gameEnded) {
            gameOver.setResult(this.score, this.elapsed);
            void this.app.scene.setScene(gameOver);
            return;
        }

        this.timeText.text = `${this.elapsed.toFixed(1)} s`;
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.world);
        context.render(this.scoreText);
        context.render(this.timeText);
    }

    override destroy(): void {
        for (const orb of this.orbs) {
            orb.gfx.destroy();
        }
        this.world?.destroy();
        super.destroy();
    }
}

// #region guide:game-over-scene
class GameOverScene extends Scene {
    private title!: Text;
    private stats!: Text;
    private hint!: Text;
    private finalScore = 0;
    private finalTime = 0;

    setResult(score: number, time: number): void {
        this.finalScore = score;
        this.finalTime = time;
    }

    override init(): void {
        this.title = new Text('GAME OVER', {
            align: 'center',
            fillColor: new Color(255, 80, 80),
            fontSize: 52,
            fontWeight: 'bold',
        });
        this.title.setAnchor(0.5);
        this.title.setPosition(CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 - 70);

        this.stats = new Text(`Score: ${this.finalScore}   Time: ${this.finalTime.toFixed(1)} s`, {
            align: 'center',
            fillColor: Color.white,
            fontSize: 26,
        });
        this.stats.setAnchor(0.5);
        this.stats.setPosition(CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2);

        this.hint = new Text('Press Space or R to play again', {
            align: 'center',
            fillColor: new Color(160, 160, 160),
            fontSize: 18,
        });
        this.hint.setAnchor(0.5);
        this.hint.setPosition(CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 + 70);

        const restart = (): void => {
            void this.app.scene.setScene(play);
        };
        this.inputs.onTrigger(Keyboard.Space, restart);
        this.inputs.onTrigger(Keyboard.R, restart);
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.title);
        context.render(this.stats);
        context.render(this.hint);
    }
}
// #endregion guide:game-over-scene

const play = new PlayScene();
const gameOver = new GameOverScene();

app.start(play);

Where to go next

From here you can extend Orb Dodge in several directions:

  • KeyboardKeyboard — rebindable actions, action mapping
  • HUD overlayHUD overlay — push a dedicated overlay scene on top instead of rendering HUD inside the game scene
  • Game feelGame feel — add a screen shake or color flash when a danger orb is collected
  • DeploymentDeployment — build and host the finished game
  • TroubleshootingTroubleshooting — blank canvas, input not firing, and other common issues