Guide

GuideRecipesBuild 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({
  scenes: { PlayScene, GameOverScene },
  canvas: {
    width: CANVAS_WIDTH,
    height: CANVAS_HEIGHT,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: new Color(10, 14, 26),
});

clearColor sets the background the engine paints at the start of each frame, before draw runs. 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:

examples/showcase/orb-dodge.ts
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;

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 carries elapsed time
  • draw(context) — per-frame rendering, onto an already-cleared frame

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:

examples/showcase/orb-dodge.ts
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;
  });
}

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

examples/showcase/orb-dodge.ts
override update(delta: Seconds): void {
  const app = this.app;
  this.elapsed += delta;
  this.spawnTimer += delta;

  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;
    this.py += (this.dy / mag) * PLAYER_SPEED * delta;
  }
  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);

  // #region guide:collision-loop
  let gameEnded = false;
  const survived: OrbData[] = [];

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

    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) {
    void app.scenes.change(GameOverScene, { data: { score: this.score, time: this.elapsed } });
    return;
  }

  this.timeText.text = `${this.elapsed.toFixed(1)} s`;
  // #endregion guide:collision-loop
}

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:

examples/showcase/orb-dodge.ts
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 });
}

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:

examples/showcase/orb-dodge.ts — collision loop
let gameEnded = false;
const survived: OrbData[] = [];

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

  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) {
  void app.scenes.change(GameOverScene, { data: { score: this.score, time: this.elapsed } });
  return;
}

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

Orbs that leave the canvas are also removed. After the loop, if gameEnded is true, call change 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:

examples/showcase/orb-dodge.ts
override draw(context: RenderingContext): void {
  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 declares its activation-data shape (GameOverData) and receives the final score and time via change’s data option — init(data) reads data.score / data.time directly, so there’s no separate mutator method or intermediate state to keep in sync:

examples/showcase/orb-dodge.ts — GameOverScene
interface GameOverData {
  score: number;
  time: number;
}

class GameOverScene extends Scene<GameOverData> {
  private title!: Text;
  private stats!: Text;
  private hint!: Text;

  override init(data: Readonly<GameOverData>): void {
    const app = this.app;
    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: ${data.score}   Time: ${data.time.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 app.scenes.change(PlayScene);
    };
    this.inputs.onTrigger(Keyboard.Space, restart);
    this.inputs.onTrigger(Keyboard.R, restart);
  }

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

When the player restarts, change(PlayScene) switches back. SceneDirector always constructs a fresh PlayScene instance and calls its init() — the scene starts 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 are registered by constructor in the application setup, and navigate to each other by constructor — never by a pre-built instance.

PlayScene calls void this.app.scenes.change(GameOverScene, { data: { score: this.score, time: this.elapsed } }). GameOverScene calls void this.app.scenes.change(PlayScene). Each call constructs a brand-new instance of the target scene — there’s no shared mutable state between activations, and no forward-reference ordering to worry about, since both classes are declared once and referenced by name.

Cleanup in destroy

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

examples/showcase/orb-dodge.ts
override destroy(): void {
  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 DodgeKeyboardOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, Container, FixedResolutionCanvasSizing, Graphics, Keyboard, type RenderingContext, Scene, type Seconds, 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;
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: Seconds): void {
    const app = this.app;
    this.elapsed += delta;
    this.spawnTimer += delta;

    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;
      this.py += (this.dy / mag) * PLAYER_SPEED * delta;
    }
    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, orb.vy * delta);

      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) {
      void app.scenes.change(GameOverScene, { data: { score: this.score, time: this.elapsed } });
      return;
    }

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

  override draw(context: RenderingContext): void {
    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();
  }
}

interface GameOverData {
  score: number;
  time: number;
}

class GameOverScene extends Scene<GameOverData> {
  private title!: Text;
  private stats!: Text;
  private hint!: Text;

  override init(data: Readonly<GameOverData>): void {
    const app = this.app;
    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: ${data.score}   Time: ${data.time.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 app.scenes.change(PlayScene);
    };
    this.inputs.onTrigger(Keyboard.Space, restart);
    this.inputs.onTrigger(Keyboard.R, restart);
  }

  override draw(context: RenderingContext): void {
    context.render(this.title);
    context.render(this.stats);
    context.render(this.hint);
  }
}
const app = new Application({
  scenes: { PlayScene, GameOverScene },
  canvas: {
    width: CANVAS_WIDTH,
    height: CANVAS_HEIGHT,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: new Color(10, 14, 26),
});
await app.start(PlayScene);

Where to go next

From here you can extend Orb Dodge in several directions:

  • Keyboard — Keyboard — rebindable actions, action mapping
  • HUD overlay — HUD overlay — add screen-fixed UI to the game scene through scene.ui
  • Game feel — Game feel — add a screen shake or color flash when a danger orb is collected
  • Deployment — Deployment — build and host the finished game
  • Troubleshooting — Troubleshooting — blank canvas, input not firing, and other common issues