← 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

Where Excalibur fits

A game has two halves, and Excalibur is only ever the second:

Half File Runs Excalibur?
The rules src/game.ts On our server No — plain TypeScript, ES2015, no engine
The bundle src/main.ts and the rest In the player's browser Yes — it draws and takes input

For a pooled game the rules are a ServerGame (see determinism). The server plays them; your Excalibur scene draws what the server sends and sends what the player does. It never has the seed, never steps the game and never counts a score. For a head-to-head game the pattern is the same with a match handler in place of the ServerGame — the Tic-Tac-Toe tutorial builds one.

Draw from frames

import * as ex from "excalibur";
import { createSoloClient } from "@crazy8s/game-sdk";
import { UP, RIGHT, DOWN, LEFT, type MyView } from "./game.ts";   // types and constants only

let view: MyView | null = null;

const client = createSoloClient<MyView>({
  // Every frame: the view, and the server's clock. Redraw from it.
  onFrame: (frame, previous) => {
    view = frame.state;
    refreshActors(view, previous?.state ?? null);   // your code: move actors to where the view says
  },
  // The run is over. `score` is the server's count.
  onOver: (score) => showFinal(score),
});

// Input sends a move — an action and at most one whole number. The next frame
// shows what became of it; draw nothing optimistically.
engine.input.keyboard.on("press", (event) => {
  if (event.key === ex.Keys.Up) client.send("move", UP);
});

Time left on the clock is client.tickNow() against your view's round length — it counts on between frames and re-anchors on each.

Frame time is yours

Nothing the bundle does decides anything, so animation may use elapsed time freely:

class Tile extends ex.Actor {
  onPostUpdate(engine: ex.Engine, delta: number) {
    this.pos = this.pos.add(this.vel.scale(delta / 1000));   // ✅ rendering only
  }
}

The one thing a bundle must not do is draw a board the server has not sent. A tile shown where the server did not put it is a board that does not exist.

Randomness

Anything that decides the game — a deal, a spawn, the next piece — is drawn in your rules, from the Prng the server passes to init. Your bundle is never given the seed. Random numbers in the bundle are for looks only: the spread of a burst of particles.

The rules

// src/game.ts — runs on the server. ES2015, no DOM, rules imports only.
import type { Prng, RecordedInput, ServerGame } from "@crazy8s/game-sdk/rules";

export const myGame: ServerGame<MyState, MyView> = {
  init: (prng, config) => buildLevel(prng, config),
  update: (state, inputs, tick) => step(state, inputs, tick),
  isComplete: (state, tick) => tick >= state.roundTicks,
  score: (state) => state.score,
  view: (state) => ({ board: state.board, score: state.score, roundTicks: state.roundTicks }),
};

Proving the rules are fair before you upload

selfTest plays your rules repeatedly from several seeds and checks they give the same answer every time and actually use the seed:

import { selfTest } from "@crazy8s/game-sdk";
import { myGame } from "./game.ts";

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 A clock, or iteration over an unordered collection, in update
Certification rejects the rules Math.random() in game.ts — search it
The server will not build the rules An ES2016+ method (includes, padStart, Object.values…) or a browser API in game.ts
A tap shows, then jumps back The bundle drew the move before the server's frame — wait for the frame
Physics decides something Excalibur physics runs in the browser; anything that decides belongs in the rules
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;
  }
}

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.