← All documentation

Building with Excalibur.js

Excalibur is the most direct fit of the three supported engines: it is TypeScript, it runs in the browser, and it can import the SDK directly. If you are choosing an engine and have no other constraint, start here.

Read the trust model first. It explains why the rules below exist.


Setup

npm create vite@latest my-game -- --template vanilla-ts
cd my-game
npm install excalibur
npm install @crazy8s/game-sdk

The two rules, in Excalibur terms

Fixed timestep

Excalibur supports a fixed update rate. Turn it on and put all game logic in onFixedUpdate, never onPreUpdate or onPostUpdate:

const engine = new ex.Engine({
  width: 800,
  height: 600,
  fixedUpdateFps: 60,   // required — logic runs at exactly this rate
});
class Player extends ex.Actor {
  // ✅ deterministic — called at a fixed rate
  onFixedUpdate(engine: ex.Engine) {
    this.tick++;
  }

  // ❌ never put logic here — elapsed time varies per device
  onPostUpdate(engine: ex.Engine, delta: number) {
    this.sprite.rotation += delta * 0.01;   // rendering only, fine
  }
}

Rendering may use frame time freely. The distinction is between what the player sees and what the game decides.

Seeded randomness

Excalibur ships ex.Random, but seed it from the platform rather than letting it seed itself — and prefer the SDK's generator, which is the one the server replays with:

import { Prng } from "@crazy8s/game-sdk";

let prng: Prng;

// seed arrives in the init message — see the harness below
prng = new Prng(seed);

const spawnX = prng.nextInt(0, 800);       // ✅
const spawnY = Math.random() * 600;        // ❌ certification rejects this

The harness

Every game needs the same small piece of glue. Copy this and fill in the middle.

import { Prng, PROTOCOL_VERSION, type HostMessage, type RecordedInput } from "@crazy8s/game-sdk";

let prng: Prng;
let tick = 0;
let running = false;
const inputs: RecordedInput[] = [];

function send(message: unknown) {
  parent.postMessage(message, "*");
}

/** Record every player action. This is what the server replays. */
export function recordInput(action: string, value?: number) {
  if (!running) return;
  inputs.push(value === undefined ? { tick, action } : { tick, action, value });
}

window.addEventListener("message", (event) => {
  if (event.source !== parent) return;
  const message = event.data as HostMessage;

  if (message.type === "init") {
    prng = new Prng(message.seed);
    buildLevel(prng, message.config);          // your code
    send({ type: "ready", protocolVersion: PROTOCOL_VERSION });
  }

  if (message.type === "start") {
    running = true;
    engine.start();
  }
});

// In your fixed-update loop:
function onFixedUpdate() {
  tick++;
  if (isGameOver()) {                          // your code
    running = false;
    send({
      type: "complete",
      finalTick: tick,
      displayScore: currentScore(),            // display only
      inputs,
    });
  }
}

Proving it is deterministic before you upload

Extract your game logic into a DeterministicGame and let the SDK check it:

import { selfTest, type DeterministicGame } from "@crazy8s/game-sdk";

const myGame: DeterministicGame<MyState> = {
  init: (prng, config) => buildLevel(prng, config),
  update: (state, inputs, tick, prng) => step(state, inputs),
  isComplete: (state, tick) => tick >= state.roundTicks,
  score: (state) => Math.round(state.score),
};

const result = selfTest(myGame);
if (!result.passed) console.error(result.failures);

Run it in your test suite. Certification runs the same check.

Building

npm run build

Ship the built output plus a manifest.json. See the lifecycle guide.

Size

Excalibur is comfortably inside the 10MB budget — the engine is a few hundred kilobytes. Your assets will dominate. Compress audio and use texture atlases before considering anything else.

Common mistakes

Symptom Cause
selfTest fails intermittently Logic in onPostUpdate instead of onFixedUpdate
Certification rejects the bundle Math.random() somewhere — search your source
Score differs from what the player saw Floating-point score; round it to an integer
Physics behaves differently across devices Excalibur physics uses floats — keep it out of scoring decisions
The board never changes colour this.color was assigned after construction — see below

Recolour the graphic, not the actor

Passing color: to an Actor constructor builds a graphic once. Assigning this.color afterwards does not reliably repaint it: text and opacity changes show, and the colour silently does not. Both this platform's own Tic-Tac-Toe and the first version of the starter template had the bug — hover did nothing and the winning line was never highlighted, with no error anywhere.

Keep a reference to the graphic and recolour that:

class Cell extends ex.Actor {
  private tile: ex.Rectangle;

  constructor() {
    super({ width: CELL, height: CELL });          // no `color:` here
    this.tile = new ex.Rectangle({ width: CELL, height: CELL, color: COLOR.cell });
    this.graphics.use(this.tile);
  }

  highlight(won: boolean): void {
    this.tile.color = won ? COLOR.win : COLOR.cell;
  }
}

Related

Render on Canvas 2D unless you need a GPU

Call engine.useCanvas2DFallback() straight after constructing your engine:

const engine = new ex.Engine({ /* … */ });
engine.useCanvas2DFallback();

A WebGL context is lent to the page, not owned by it, and the browser reclaims it on a GPU reset, a driver update, or another tab making demands. Excalibur's response is to stop the clock and cover your game with a white panel reading "There was an issue rendering, please refresh the page" — a developer's diagnostic shown to a player who was part-way through a staked match. A player of ours saw exactly that.

Flat shapes, text and images do not need a GPU, and a 2D canvas has no context to lose. Every first-party game here is on Canvas 2D for that reason; see ADR 0014.

If your game genuinely needs WebGL — particles, shaders, hundreds of sprites — leave the call out, and handle the loss yourself with watchRenderHealth from the SDK. The default handling is that panel.