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;
}
}
Related
- Getting started — the path end to end
- Trust model — what your game may and may not decide
- Writing the server rules — the half that decides outcomes
- Determinism — proving your rules are reproducible
- Publishing lifecycle — review, updates, revenue
- Building a client — for a front end, not a game
- Server API reference — every RPC, and who may call it
- Engine guides: Excalibur.js · Phaser · Defold
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.
