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
- 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 · Defold · GDevelop
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.
