← All documentation

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.projecthtml5htmlfile):

<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.projectapp_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