Writing deterministic server rules
You write the rules that decide who wins. They run on our servers, and they must produce the same answer every single time.
This is not a style preference. A rules module that behaves differently on two runs does not produce a bug — it produces a payout nobody can justify and a dispute nobody can resolve, because there is no reproducible account of what happened.
Every game is certified against this before it can decide anything. Certification is mechanical, it runs on all three categories, and it cannot be waived.
What determinism means here, exactly
Given the same seed and the same ordered sequence of player actions, your rules must produce byte-identical state, an identical result, and an identical tick count — every time, on every machine.
Two runs are compared by canonical serialisation, so object key order does not matter but every value does.
The five ways rules break
Certification detects the first four. The fifth it does not, and cannot — see below.
1. Randomness that is not ours
// ❌ rejected — the server cannot reproduce this
const roll = Math.random() * 6;
// ✅ the seeded generator, passed into every rules function
const roll = prng.nextInt(0, 6);
Certification replaces Math.random during the run, so any use throws and is reported by
name. It cannot be hidden by minification or an indirect call, because the behaviour is
what is trapped, not the text.
2. Reading the clock
// ❌ rejected — results would depend on how fast the machine is
if (Date.now() - state.started > 30000) endRound();
// ✅ tick counts are the same everywhere
if (tick - state.startedTick > 30 * tickRate) endRound();
Date.now is trapped the same way.
3. Iteration order you do not control
// ❌ risky — key order depends on insertion history, which can vary
for (const id of Object.keys(state.players)) { ... }
// ✅ sort, so the order is a property of the data rather than of history
for (const id of Object.keys(state.players).sort()) { ... }
This one is not trapped — it is caught by running your rules repeatedly and comparing. It is the most common cause of a game that passes locally and fails certification.
4. Floating-point accumulation in anything that decides
// ❌ two devices can differ in the last bit, and comparison is exact
state.score += 0.1;
// ✅ integers compare exactly
state.scoreMinor += 100;
Scores must be integers. Certification rejects a non-integer score outright.
5. Maths the language does not pin down
This is the one certification cannot catch, and the one most likely to reach a paying player before anybody notices.
ECMAScript specifies +, -, *, / and Math.sqrt exactly: every conforming
engine returns the same bits. It explicitly does not specify Math.sin, Math.cos,
Math.tan, Math.atan, Math.atan2, Math.pow, Math.exp, Math.log or Math.hypot —
implementations are free to approximate, and are allowed to disagree.
Your client runs in the player's browser. The verifier runs in Goja, on the server. They are different implementations.
// ❌ one unit in the last place is enough to change the answer
const dx = Math.cos(angle) * speed;
// ✅ a table of integer directions, generated at build time
const dx = AIM[aimIndex][0];
Certification runs your rules against itself, in one engine, so this passes every check and then fails in production — as a replay mismatch, which throws out the player's run. On a staked game that is somebody's money, with nothing to tell them what went wrong.
The rule: if your rules involve geometry, use no floating point at all. Work in fixed-point integers, compare squared distances rather than taking roots, and bake any trigonometry into a table at build time instead of computing it at run time.
games/bubble-pop is the worked example — the only game here with a trajectory. Its
aim directions are 121 integer vectors generated once by a script, its positions are in
units of 1/512, and it has a test that greps its own source and fails if any of the
unsafe functions appears.
Math.sqrt is safe if you need it: IEEE 754 requires it to be correctly rounded.
Math.round, Math.floor, Math.abs, Math.min and Math.max are all exact.
Your rules must actually use the seed
Certification wraps the generator and counts draws during init. Zero draws is a
failure, not a warning.
A game that ignores its seed gives every pool an identical puzzle — so a player can learn it once and repeat it indefinitely. That is not a subtle fairness issue; it is a way to farm the platform.
// ❌ rejected — every match starts identically
init: (ctx) => ({ board: [1, 2, 3, 4, 5] }),
// ✅ the board comes from the platform's seed
init: (ctx, prng) => ({ board: prng.shuffle([1, 2, 3, 4, 5]) }),
What each category is certified for
| Category | Certified on |
|---|---|
realtime-authoritative |
Identical state, result and current player across runs; seed is used; the per-player view does not leak hidden state |
async-pool |
Identical state and integer score across runs; seed is used |
realtime-simulated |
Divergence measurement is itself deterministic; tolerance is finite and non-negative |
That last one deserves a note. If your divergence detection is unstable, honest players get
voided at random — which is worse than not checking at all. A tolerance of exactly 0 is
allowed but warned about, because two honest floating-point simulations will always differ
slightly.
Running certification yourself
Run it before you upload. It is the same code we run.
import { certifyRealtimeGame } from "@crazy8s/engine";
const report = certifyRealtimeGame(myRules, {
context: { matchId: "test", seed: 42, players: [...], config: {} },
actions: [
{ playerId: "alice", action: "play", payload: { card: 3 } },
{ playerId: "bob", action: "play", payload: { card: 7 } },
],
ticks: 100,
});
if (!report.passed) console.error(report.failures);
Give it a scenario that resembles a real match. Certification can only exercise the paths your scenario reaches, so a thin scenario produces a weak guarantee — that is on you, not on the tool.
A useful habit
Write a test that plays a full match twice and compares the results. If it ever fails, you have found a determinism bug in a place where it costs nothing. Finding the same bug after a payout costs a great deal more.
Where your rules run
First-party rules run inside the platform's runtime. Third-party rules run in their own container, with CPU and memory limits and no network access.
You write the same code either way — the contract is identical and your rules cannot tell the difference. The isolation exists because certification can prove your rules are deterministic but cannot stop an infinite loop, and only the operating system can do that.
See ADR 0005 if you want the reasoning.
Related
- Getting started — the path end to end
- 2048, worked end to end — this page's rules applied to a real pooled game
- Snake — the same, with real-time input
- Trust model — what your game may and may not decide
- Making a game feel good — the render-only half, and why it must stay that way
- Writing the server rules — the half that decides outcomes
- 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
