Building with Defold
Defold produces the smallest HTML5 builds of the three supported engines — an empty project is around 1MB — which leaves the most room for assets inside the 10MB budget. The trade-off is that Lua and JavaScript have to talk to each other, and Lua's own random generator cannot be used.
Read the trust model first. It explains why the rules below exist.
Two things Defold makes you do differently
1. math.random is unusable — implement the SDK generator in Lua
Lua's random generator cannot be reproduced by our server, so the puzzle it builds would differ from the one we replay. Implement the same algorithm the SDK uses. It is short, and it is the identical algorithm on both sides:
-- prng.lua — xoshiro128**, matching @crazy8s/game-sdk exactly.
-- Integer-only: floating point can differ in the last bit between devices,
-- and one bit sends the puzzle down a different path.
local M = {}
local band, bxor, lshift, rshift = bit.band, bit.bxor, bit.lshift, bit.rshift
local function u32(x) return band(x, 0xFFFFFFFF) end
local function rotl(x, k) return u32(bxor(lshift(x, k), rshift(x, 32 - k))) end
function M.new(seed)
local s = {}
local x = u32(seed)
for i = 1, 4 do
x = u32(x + 0x9E3779B9)
local z = x
z = u32(bxor(z, rshift(z, 16)) * 0x21F0AAAD)
z = u32(bxor(z, rshift(z, 15)) * 0x735A2D97)
s[i] = u32(bxor(z, rshift(z, 15)))
end
if s[1] + s[2] + s[3] + s[4] == 0 then s[1] = 1 end
local self = { s = s }
function self.next_uint32()
local result = u32(rotl(u32(s[2] * 5), 7) * 9)
local t = u32(lshift(s[2], 9))
s[3] = bxor(s[3], s[1]); s[4] = bxor(s[4], s[2])
s[2] = bxor(s[2], s[3]); s[1] = bxor(s[1], s[4])
s[3] = bxor(s[3], t); s[4] = rotl(s[4], 11)
return result
end
function self.next_int(min, max)
local range = max - min
local limit = math.floor(4294967296 / range) * range
local v = self.next_uint32()
while v >= limit do v = self.next_uint32() end
return min + (v % range)
end
return self
end
return M
Use it everywhere you would have used math.random:
local prng = require("main.prng")
self.rng = prng.new(seed)
local x = self.rng.next_int(0, 800) -- ✅
local y = math.random(0, 600) -- ❌ certification rejects this
2. Use fixed_update, not update
Defold's update(self, dt) is called per frame with variable dt. Game logic must not
depend on it. Set a fixed update frequency in game.project:
[engine]
fixed_update_frequency = 60
Then put logic in fixed_update and rendering in update:
function fixed_update(self, dt)
-- ✅ called at exactly 60Hz. All game decisions here.
self.tick = self.tick + 1
step_simulation(self)
end
function update(self, dt)
-- Rendering and animation only. Never a game decision.
animate_sprites(self, dt)
end
Talking to the platform
Defold reaches JavaScript through html5.run(). The pattern is a small JS shim that owns
the message protocol, with Lua polling it.
Add to your index.html template (Defold lets you customise this in
game.project → html5 → htmlfile):
<script>
window.C8 = { inbox: [], seed: null, running: false, inputs: [] };
window.addEventListener("message", function (event) {
if (event.source !== parent) return;
window.C8.inbox.push(event.data);
});
window.c8_poll = function () {
var message = window.C8.inbox.shift();
return message ? JSON.stringify(message) : "";
};
window.c8_send = function (json) {
parent.postMessage(JSON.parse(json), "*");
};
</script>
In Lua:
local function poll()
local raw = html5.run("window.c8_poll()")
if raw == "" then return nil end
return json.decode(raw)
end
local function send(message)
html5.run("window.c8_send(" .. json.encode(json.encode(message)) .. ")")
end
function fixed_update(self, dt)
local message = poll()
if message then
if message.type == "init" then
self.rng = prng.new(message.seed)
build_level(self, message.config)
send({ type = "ready", protocolVersion = 1 })
elseif message.type == "start" then
self.running = true
end
end
if not self.running then return end
self.tick = self.tick + 1
step_simulation(self)
if is_game_over(self) then
self.running = false
send({
type = "complete",
finalTick = self.tick,
displayScore = self.score, -- display only
inputs = self.inputs, -- what the server replays
})
end
end
Record every player action as { tick = self.tick, action = "tap", value = index }.
Building
Project → Bundle → HTML5 Application. Ship the output plus a manifest.json.
Strip unused engine components in game.project → app_manifest — Defold 1.12 and later
excludes unused code from HTML5 builds automatically, which is worth a few hundred
kilobytes.
Size
An empty Defold HTML5 project is roughly 1MB, and builds are WebAssembly-only from 1.13. That leaves about 9MB for assets. In practice audio is what overruns the budget: compress to Ogg at a modest bitrate before reducing texture quality.
Common mistakes
| Symptom | Cause |
|---|---|
| Certification rejects the bundle | math.random left in the Lua source |
| Results vary between runs | Logic in update instead of fixed_update |
| The game never becomes ready | The JS shim is missing from your custom index.html |
html5.run returns nothing |
Defold escapes strings — json.encode twice, as above |
| Physics diverges | Defold's physics uses floats; keep it out of scoring decisions |
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
