Building with GDevelop
GDevelop is the fastest way to get a playable game, and the most work to make verifiable. Its visual event system is convenient precisely because it hides timing and randomness — the two things the platform needs you to control explicitly.
Everything below is achievable, but expect to write more JavaScript blocks than a typical GDevelop project uses.
Read the trust model first. It explains why the rules below exist.
The two things GDevelop does that you must replace
1. Built-in random expressions cannot be used
Random(), RandomInRange(), RandomFloatInRange() and RandomWithStep() all use the
engine's own generator. Our server cannot reproduce them, so the puzzle a player sees would
differ from the one we replay.
Replace them with a JavaScript block holding the SDK generator.
Create a JavaScript block that runs once at scene start:
// Integer-only xoshiro128**, matching @crazy8s/game-sdk exactly.
// Floating point can differ in the last bit between devices, and one bit is
// enough to send the puzzle down a different path.
const u32 = (x) => x >>> 0;
const rotl = (x, k) => u32((x << k) | (x >>> (32 - k)));
function makePrng(seed) {
let x = u32(seed);
const s = [];
for (let i = 0; i < 4; i++) {
x = u32(x + 0x9e3779b9);
let z = x;
z = u32(Math.imul(z ^ (z >>> 16), 0x21f0aaad));
z = u32(Math.imul(z ^ (z >>> 15), 0x735a2d97));
s.push(u32(z ^ (z >>> 15)));
}
if ((s[0] | s[1] | s[2] | s[3]) === 0) s[0] = 1;
return {
nextUint32() {
const result = u32(Math.imul(rotl(u32(Math.imul(s[1], 5)), 7), 9));
const t = u32(s[1] << 9);
s[2] = u32(s[2] ^ s[0]); s[3] = u32(s[3] ^ s[1]);
s[1] = u32(s[1] ^ s[2]); s[0] = u32(s[0] ^ s[3]);
s[2] = u32(s[2] ^ t); s[3] = rotl(s[3], 11);
return result;
},
nextInt(min, max) {
const range = max - min;
const limit = Math.floor(0x100000000 / range) * range;
let v = this.nextUint32();
while (v >= limit) v = this.nextUint32();
return min + (v % range);
},
};
}
runtimeScene.getGame().c8prng = makePrng(runtimeScene.getGame().c8seed);
Then use it wherever you would have used a random expression:
// In a JavaScript block, writing into a scene variable your events can read.
const prng = runtimeScene.getGame().c8prng;
runtimeScene.getVariables().get("spawnX").setNumber(prng.nextInt(0, 800));
Your visual events then read Variable(spawnX) as normal.
2. TimeDelta() cannot drive game logic
GDevelop events run once per frame, and TimeDelta() varies with device speed. Any logic
using it produces different results on different phones.
Use a tick counter instead. Add a JavaScript block at the top of your event sheet:
const vars = runtimeScene.getVariables();
vars.get("tick").setNumber(vars.get("tick").getAsNumber() + 1);
Then drive everything from Variable(tick):
| Instead of | Use |
|---|---|
TimeDelta() in a movement calculation |
a fixed step per tick |
| "wait 2 seconds" | Variable(tick) >= 120 at 60 ticks per second |
| A timer object | a tick comparison |
Movement and animation rendering may use TimeDelta(). Anything that changes the score
or the game state must not.
Talking to the platform
Add one JavaScript block at scene start:
const game = runtimeScene.getGame();
game.c8 = { inputs: [], running: false };
window.addEventListener("message", (event) => {
if (event.source !== parent) return;
const message = event.data;
if (message.type === "init") {
game.c8seed = message.seed;
game.c8config = message.config;
parent.postMessage({ type: "ready", protocolVersion: 1 }, "*");
}
if (message.type === "start") {
game.c8.running = true;
}
});
Record every player action — this is what the server replays:
const game = runtimeScene.getGame();
const tick = runtimeScene.getVariables().get("tick").getAsNumber();
game.c8.inputs.push({ tick, action: "tap", value: tileIndex });
Report completion:
const game = runtimeScene.getGame();
game.c8.running = false;
parent.postMessage({
type: "complete",
finalTick: runtimeScene.getVariables().get("tick").getAsNumber(),
displayScore: Math.round(runtimeScene.getVariables().get("score").getAsNumber()),
inputs: game.c8.inputs,
}, "*");
Note the Math.round — GDevelop variables are floating point, and scores must be integers.
Building
File → Export → Web (HTML5), choosing local export rather than GDevelop's hosting. Ship
the exported folder plus a manifest.json.
Size
GDevelop exports are larger than Defold's — typically 2–4MB before your assets. Still comfortably within 10MB, but check before adding uncompressed audio.
Honest assessment
If you are choosing an engine now and your game suits it, Excalibur.js is a better fit for this platform: it is code-first, so determinism is something you write rather than something you work around.
GDevelop is the right choice when you already know it, or when the visual editor is what makes the game possible for you. It just means more JavaScript blocks than usual, and more care in testing.
Common mistakes
| Symptom | Cause |
|---|---|
| Certification rejects the bundle | A Random() expression left in your events |
| Results vary between runs | TimeDelta() or a timer object driving game logic |
| Score rejected as non-integer | GDevelop variables are floats — round before sending |
| The game never becomes ready | The message listener block did not run at scene start |
| Works in preview, fails when exported | Preview and export differ in timing — always test the export |
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
