← All documentation

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