TL;DR: I've been writing Lua for years across two very different runtimes: Project Zomboid mods (Wheelbarrow, Firetrail, Dragon Radar w/ 130k+ Workshop subs combined, top mod alone broke 140k subscribers) and now a full browser arcade game in Defold (WASM-compiled Lua 5.1, HTML5 export). Sharing what i learned about Lua specifically: the gotchas, the patterns that scaled, and the surprises going from "modding inside someone else's runtime" to "shipping a full game from scratch."
Hey Reifel here. This will be very technical
Quick background since it shapes the rest of this post: i spent the last few years writing Lua mods for Project Zomboid. PZ exposes a Java-side game engine to Lua hooks — you write Lua that gets injected into the running game and called via event callbacks. My most-used mod broke 140k subscribers on the Steam Workshop, and the combined catalog (Wheelbarrow, Firetrail, Dragon Radar) is past 130k. It taught me Lua at a depth no tutorial would have: hot-patching live games, debugging across a JNI boundary, working in a heavily sandboxed runtime where half the standard library is missing.
Then i learned that Defold also runs on Lua, and i wanted to test whether the same muscle memory could carry me from "modding someone else's engine" to "shipping a full game." The result is SOLONE: a 30-second arcade game running entirely in HTML5 (Lua compiled to WASM under the hood), with online leaderboard, opens in any browser tab.
What surprised me most: the two runtimes share Lua 5.1 as the language but diverge wildly in everything else. PZ has a forgiving sandbox with rich Java interop and global event hooks. Defold has a strict component model with message-passing-only IPC. Same pairs, same metatables, same coroutines — completely different mental model around state and communication.
Below are the Lua-specific things that bit me, since this is the part this sub actually cares about.
Lua 5.1 in production, the parts that hurt
Both PZ and Defold run Lua 5.1, which means no goto/::label::, no integer division //, no bitwise operators outside the bit library, and no continue in loops. After a few years you stop missing them, but the patterns still bite when you forget:
-- WRONG (5.2+ thinking)
for k, v in pairs(t) do
if bad(v) then goto continue end
use(v)
::continue::
end
-- CORRECT (5.1)
for k, v in pairs(t) do
if not bad(v) then use(v) end
end
In PZ this is event-driven approach because most of your code lives inside event hooks (short functions). In Defold with a 60-frame-per-second update loop on every game object, the lack of continue shapes how you write entire systems.
The bug that ate three days: zero and false silently dropped from messages
Defold serializes inter-script messages with Protocol Buffers. Fields whose value is 0, 0.0, or false get DROPPED at the protobuf layer because they match the default value. The receiver gets nil, not the value you sent.
-- sender
msg.post(url, "config", { strategy = 0, enabled = false })
-- receiver
function on_message(self, message_id, message, sender)
print(message.strategy) -- nil, not 0
print(message.enabled) -- nil, not false
end
Fix on the receiver side is the or fallback pattern:
self.strategy = message.strategy or 0
PZ doesn't have this problem because mod-to-mod communication is direct Lua function calls through a global table. But it has its own version of the gotcha: PZ globals can be nil, false, or missing-entirely, and you need three different checks (type(x) == nil doesn't exist in 5.1, so you write x == nil, x == false, and not x depending on intent). Same lesson, different runtime: never trust truthy checks across serialization or IPC boundaries.
This same Defold bug bit my telemetry pipeline: i was sending arrays of intra-run kill counts per 5-second window, and every zero (idle window) was disappearing. Fix: json.encode the array before sending, json.decode on the server. The protobuf layer treats it as one opaque string, zeros survive.
Module-level state is SHARED across instances (classic Lua singleton trap)
Defold runs with shared_state = 1, meaning ONE Lua state for the whole game. Every .script file's module-level locals are shared across every instance of that script. So:
-- BROKEN: shared across all FPS display instances
local samples = {}
local sum = 0
function update(self, dt)
sum = sum + dt
end
-- CORRECT: per-instance state on self
function init(self)
self.samples = {}
self.sum = 0
end
function update(self, dt)
self.sum = self.sum + dt
end
The bug only shows when you have 2+ instances, which is why it survived months in my code before i caught it.
PZ taught me the inverse lesson: there everything is global by default and you fight to keep state encapsulated. In Defold, scripts feel like classes but the module body is shared. Coming from PZ i instinctively scoped state per-instance from day one — that habit saved me a lot of debugging.
vmath.normalize NaN crash
Defold's vector math has no zero-length guard on normalize. Pass it the zero vector and you get NaN propagating through your physics state. Wrapper that saved my sanity:
local function safe_normalize(v)
local len = vmath.length(v)
if len < 0.001 then return nil end
return v * (1 / len)
end
Then every caller does if dir then ... end instead of trusting the output blindly.
GO IDs as Lua table keys need tostring()
Defold's runtime IDs are numeric hashes. Using them directly as Lua table keys can collide on hash-to-integer conversion. Across 5+ months of building i never reproduced the collision, but the official guidance is firm:
self.enemies[tostring(id)] = id -- always tostring the key
PZ has a similar gotcha with Java object references as keys (the tostring of a Java object includes its hash code, but two different objects can stringify to the same prefix in some PZ builds). Different language interop, same defensive pattern.
What pleasantly surprised me about Lua at this scale
A few patterns that just worked across BOTH runtimes:
Coroutines for sequenced flows. In PZ for multi-step crafting timers ("pour gas, wait, ignite, propagate"), in Defold for animation sequencing ("fade in, hold, fade out, delete"). Cleaner than callback chains in either runtime.
Tables as ring buffers. 30-frame FPS averager with one table and a rolling index, zero allocations per frame. Same trick i use in PZ for tracking damage-over-time effects.
Pure stateless helper modules.
local M = {}+return Mpattern works identically in both runtimes. My vmath helpers, color palettes, and AI strategy lookups port between projects with no changes.html5.run() as a Lua-to-JS bridge. Defold-only, but worth highlighting: i call browser APIs (PWA install prompt, navigator.share, soft keyboard handshake on iOS Safari) directly from Lua via string-encoded JS. Surprisingly clean for what could have been an FFI nightmare.
Hot reload everywhere. Both PZ (via
/reloadlua) and Defold (save and the engine reloads scripts live) made tuning constants almost zero-friction. This is one Lua advantage i don't think gets talked about enough — when your game logic is a script, balance work happens in seconds instead of recompiles.
Try it (Lua running in your browser via WASM)
Web build: https://mapafome.com.br/solone
itch.io: https://reifel.itch.io/solone
Engine: Defold, all gameplay/UI/networking in Lua, current version v1.8.97
If anyone here has shipped Lua across runtimes (Love2D, Defold, PICO-8, World of Warcraft AddOns, OpenResty, modded Minecraft, Roblox-style sandboxes, Project Zomboid), i'd love to compare notes on:
State isolation when the host runtime shares one Lua state across many "instances"
Serialization layers that drop falsy values (the protobuf zero-drop is not unique to Defold)
Debugging Lua across language boundaries (JNI, WASM, FFI), where stack traces lose source maps
My Lua catalog so far (140.000+ users)
Wheelbarrow (PZ, 140k+ subs) to carry logs and heavy items while building
Firetrail (PZ) to pour gasoline in a line and ignite, used for corpse cleanup
Dragon Radar (PZ) to locate wanted items on the map
PZRank leaderboard (PZ + web) for community survival ranking, mod writes scores via Lua, a small Node service aggregates them
Workshop stats dashboard (web) for Lua mod metrics scraped and visualized
SOLONE (Defold, all-Lua) the current project this post is about
Happy to dive into any specific Lua/Defold/PZ-modding question in the comments. Discord is @reifel1 (server) for shop talk.