How to set up a fully headless Defold development environment in the cloud, complete with unit testing, linting, and a live HTML5 demo — no desktop engine install required.
Why?
Defold is a fantastic free game engine, but its editor is a desktop application. If you want to develop Defold games on a Chromebook, an iPad, a locked-down work laptop, or just want your entire toolchain reproducible in the cloud, GitHub Codespaces is a great answer. This guide walks through everything I learned setting up a complete Defold → HTML5 pipeline from scratch: building with bob.jar, testing Lua with busted, linting with luacheck, formatting with StyLua, and serving a playable “Hello World” demo — all inside a browser tab.
What is Defold?
Defold is a free, open-source, cross-platform game engine developed by the Defold Foundation (originally by King). It’s designed for 2D games with first-class support for HTML5/WebAssembly, making it ideal for web games, instant games, and playable ads. Here’s what makes it stand out:
- Zero cost, no royalties — completely free for commercial use, no revenue sharing, no seat licenses.
- Lightweight & fast — the engine core is ~1-2 MB; HTML5 builds start around 2-3 MB gzipped. Cold starts are near-instant.
- Lua + native extensions — game logic is written in Lua 5.1 (LuaJIT), with the ability to drop into C/C++/Rust for performance-critical code via native extensions.
- Component-based architecture — game objects are composed of components (sprites, labels, collision objects, scripts, factories, etc.) attached to collections (scenes). No heavy inheritance hierarchies.
- Built-in physics — Box2D for 2D physics with collision groups, raycasts, and joints.
- First-class HTML5/WASM — one-click (or one-command) export to WebAssembly with asm.js fallback. Supports PWA, WebGL 1/2, and custom engine templates.
- Live update — push content updates to live games without app store review (mobile) or re-deployment (web).
- Headless CI-friendly build tool —
bob.jaris a standalone Java CLI that compiles, bundles, and packages projects without the editor. Perfect for automation, GitHub Actions, and Codespaces. - Small, helpful community — active forums, Discord, and extensive documentation. The engine has been battle-tested in production games like Plague Inc., Cosmic Top Secret, and many King titles.
Defold’s philosophy is “batteries included but not forced” — you get a complete 2D toolkit (rendering, physics, audio, input, GUI, particles, tilemaps, Spine/DragonBones animation) without being locked into a specific workflow or visual editor. The editor is there if you want it, but the project format is plain text, so you can version-control everything and even hand-author projects like we do in this guide.
The Architecture
Defold’s build system is Java-based, which is what makes this possible. The engine ships bob.jar — the same build tool the desktop editor uses internally. Given a project folder of text assets (collections, game objects, scripts), bob compiles them into a runnable bundle for any platform, including HTML5 via WebAssembly.
Our final layout:
defold/
├── bob.jar # Defold build tool
├── setup_environment.sh # One-command environment restore
└── testproject/
├── Makefile # test / lint / fmt / build / bundle / serve
├── game.project # Engine configuration
├── .luacheckrc # Linter config with Defold globals
├── main/
│ ├── main.collection # Bootstrap collection
│ ├── hello.go # Game object (script + label components)
│ ├── hello.script # Game logic entry point
│ ├── hello.label # Text label definition
│ └── game_logic.lua # Pure-Lua module (unit tested!)
├── fonts/
│ ├── RocknRollOne-Regular.ttf
│ ├── rocknroll.font # Defold font resource
│ └── OFL.txt # License (required by SIL OFL)
└── spec/
└── game_logic_spec.lua # busted test suite
Step 1: Install the Toolchain
Note: for setting up the environment you can also copy the bash file at the bottom of the page and have an Ai agent execute it for you
Java + the system library nobody warns you about
bob.jar needs Java. Codespaces usually has it, but if not:
sudo apt-get update && sudo apt-get install -y default-jdk
The first build failure you’ll likely hit is cryptic:
UnsatisfiedLinkError: .../libmodelc_shared.so: libXi.so.6:
cannot open shared object file: No such file or directory
Headless containers lack X11 libraries that Defold’s native asset tools link against. One line fixes it:
sudo apt-get install -y libxi6
Download bob.jar
Get it from the official release (pin your version for reproducibility):
mkdir -p defold && cd defold
curl -sL -o bob.jar \
https://github.com/defold/defold/releases/download/1.13.1/bob.jar
java --enable-native-access=ALL-UNNAMED -jar bob.jar --version
Gotcha: On modern JDKs (17+), bob’s native loaders trigger restricted-access warnings and can fail outright. Always run it with --enable-native-access=ALL-UNNAMED.
Lua tooling
Defold embeds Lua 5.1 (technically LuaJIT), so match your local tools accordingly:
sudo apt-get install -y lua5.1 luarocks
sudo luarocks install luacheck # linter
sudo luarocks install busted # BDD test framework
sudo luarocks install luaunit # xUnit alternative
# StyLua formatter (binary download)
curl -sL -o /tmp/stylua.zip \
https://github.com/JohnnyMorganz/StyLua/releases/download/v2.5.2/stylua-linux-x86_64.zip
unzip /tmp/stylua.zip stylua -d /tmp && sudo mv /tmp/stylua /usr/local/bin/
# lua-language-server (IDE autocomplete/diagnostics)
LLS_URL=$(curl -s https://api.github.com/repos/LuaLS/lua-language-server/releases/latest \
| grep browser_download_url | grep linux-x64 | grep -v musl | cut -d'"' -f4 | head -1)
curl -sL -o /tmp/lls.tar.gz "$LLS_URL"
sudo mkdir -p /opt/lls && sudo tar -xzf /tmp/lls.tar.gz -C /opt/lls
sudo ln -sf /opt/lls/bin/lua-language-server /usr/local/bin/
Step 2: Create a Minimal Project
Defold projects are folders of plain-text files — perfect for hand-authoring when you can’t use the editor.
game.project — the INI-style config. Note the bootstrap paths point at compiled extensions (.collectionc); this is how bob resolves them internally:
[bootstrap]
main_collection = /main/main.collectionc
[display]
width = 640
height = 480
[input]
game_binding = /input/game.input_bindingc
main/main.collection — the root scene. It instantiates one game object:
name: "main"
instances {
id: "go"
prototype: "/main/hello.go"
position { x: 320.0 y: 240.0 z: 0.0 }
rotation { x: 0.0 y: 0.0 z: 0.0 w: 1.0 }
}
Key lesson: collections reference game object files; they don’t hold components inline. Put your script and label on hello.go.
main/hello.go — the game object owns both components. Position the object at screen center so rotations pivot correctly later:
components {
id: "hello"
component: "/main/hello.script"
position { x: 0.0 y: 0.0 z: 0.0 }
}
components {
id: "label"
component: "/main/hello.label"
position { x: 0.0 y: 0.0 z: 0.1 }
}
input/game.input_binding — maps left mouse button to the action name touch:
mouse_trigger {
input: MOUSE_BUTTON_LEFT
action: "touch"
}
Step 3: Hello World Script
local game_logic = require("main.game_logic")
function init(self)
self.game = game_logic.new()
msg.post("@render:", "clear_color", {color = vmath.vector4(0.2, 0.5, 0.9, 1.0)})
-- pulse animation
go.animate("#label", "scale.x", go.PLAYBACK_LOOP_PINGPONG, 1.2, go.EASING_INOUTSINE, 1.2)
go.animate("#label", "scale.y", go.PLAYBACK_LOOP_PINGPONG, 1.2, go.EASING_INOUTSINE, 1.2)
msg.post(".", "acquire_input_focus")
end
function on_input(self, action_id, action)
if action_id == hash("touch") and action.pressed then
self.game:add_score(1)
go.animate(".", "euler.z", go.PLAYBACK_ONCE_FORWARD, 360,
go.EASING_INOUTQUAD, 0.8, 0, function()
go.set(".", "euler.z", 0)
end)
end
end
function update(self, dt)
-- rainbow color cycling
local t = (self.t or 0) + dt
self.t = t
local r = 0.5 + 0.5 * math.sin(t * 2.0)
local g = 0.5 + 0.5 * math.sin(t * 2.0 + 2.0944)
local b = 0.5 + 0.5 * math.sin(t * 2.0 + 4.1888)
go.set("#label", "color", vmath.vector4(r, g, b, 1))
end
Three things worth noting:
- Rotate the game object (
"."), not the component ("#label") — component rotation isn’t reliably animatable. - Pivot matters — because the object sits at screen center and the label has
pivot: PIVOT_CENTER, the spin rotates around the text’s visual center instead of flinging it off-screen. - Game logic lives in a pure module — more on that below.
Step 4: Testable Architecture
Engine scripts can’t run under standard Lua interpreters — they depend on Defold globals (go, msg, vmath). The fix is classic hexagonal architecture: keep logic in pure modules and make scripts thin adapters.
-- main/game_logic.lua
local game = {}
function game.new()
local self = setmetatable({}, { __index = game })
self.score = 0
return self
end
function game:add_score(points)
assert(type(points) == "number" and points > 0, "points must be positive")
self.score = self.score + points
return self.score
end
return game
-- spec/game_logic_spec.lua
local game_logic = require("game_logic")
describe("game_logic", function()
it("adds points to the score", function()
local state = game_logic.new()
assert.equals(10, state:add_score(10))
end)
end)
Run with the module path pointing into main/:
busted --lpath="./main/?.lua" spec
Step 5: Build & Serve the HTML5 Bundle
This is where most of my debugging time went. The critical discovery: always clean-build when bundling, or bob silently reuses stale artifacts:
BOB := java --enable-native-access=ALL-UNNAMED -jar ../bob.jar
bundle:
$(BOB) --platform wasm-web -a --bundle-output build_html5 clean build bundle
serve:
cd build_html5/unnamed && python3 -m http.server 8000
Then open the forwarded port in your browser. That’s it — a real Defold game running in WASM, built entirely in the cloud.
Debugging tip: verify your deploy actually happened by comparing the archive size bob reports against what the server returns from archive/archive_files.json. Mismatch = stale bundle.
Step 6: Custom Fonts
Drop a TTF in the project and define a .font resource:
font: "/fonts/RocknRollOne-Regular.ttf"
material: "/builtins/fonts/font-df.material"
size: 48
output_format: TYPE_DISTANCE_FIELD
Critical pairing rule: distance-field fonts must use label-df.material; bitmap fonts need label-fnt.material. Mixing them produces ugly blocky outlines. Also remember labels require a size field — and it’s a Vector4 scale, not a number.
If you ship an OFL font, include its license file. It’s a legal requirement and takes two seconds.
Step 7: Disaster Recovery — The Setup Script
Everything above is captured in one idempotent script, setup_environment.sh. It checks each dependency before installing, pins versions, validates downloads, works under both root and non-root users, and finishes by running the full verification suite (tool versions + tests + lint). Run it in any fresh codespace and you’re back in business:
./setup_environment.sh
# ==> Environment fully restored and verified.
Commit it alongside the project. Your environment becomes as reproducible as your code.
Gotchas Cheat Sheet
| Gotcha | Fix |
|---|---|
Platform js-web not supported | Use wasm-web |
libXi.so.6: cannot open shared object file | apt-get install libxi6 |
| Native access errors on modern JDK | java --enable-native-access=ALL-UNNAMED |
| Changes don’t appear after rebuild | Bundle must include clean build — stale cache lies |
Error shows truncated path (main.collectio) | Display quirk; not the real problem |
| Label won’t compile | Needs text, size (Vector4!), font, AND material |
| Blocky font rendering | DF font ↔ label-df.material, bitmap ↔ label-fnt.material |
| Spin animation does nothing | Animate "." not "#component" for rotation |
| Rotation swings off-screen | Pivot = game object position; center the object, zero the component offset |
.luacheckrc syntax error | It’s Lua: -- comments and {} tables, not # or [] |
Wrapping Up
What we ended up with: a cloud-native Defold workflow with version-pinned tooling, automated quality gates (make check runs lint + format validation + 8 unit tests), one-command bundling, live preview over a forwarded port, and full disaster recovery in a single script. Total cost: free tier Codespaces hours.
The same pattern extends naturally — add a GitHub Actions workflow that runs make check && make bundle on every push and publishes build_html5/unnamed/ to GitHub Pages, and you’ve got continuous deployment for a browser game. But that’s a post for another day.
#!/usr/bin/env bash ############################################################################### # Defold HTML5 dev environment bootstrap # # Restores the complete toolchain needed to build/test/serve this project: # - bob.jar (Defold build tool) + libxi6 system dependency # - Lua 5.1, Luarocks, Luacheck, busted, luaunit (testing) # - StyLua (formatter), lua-language-server (LSP) # # Usage: # ./setup_environment.sh # install everything + verify # ./setup_environment.sh --no-verify # skip verification step # # Assumes it is run from the repository root or from defold/. ############################################################################### set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Resolve defold/ dir whether script lives in defold/ or repo root if [[ -d "$SCRIPT_DIR/defold" ]]; then DEFOLD_DIR="$SCRIPT_DIR/defold"; else DEFOLD_DIR="$SCRIPT_DIR"; fi BOB_VERSION="1.13.1" BOB_URL="https://github.com/defold/defold/releases/download/${BOB_VERSION}/bob.jar" STYLUA_VERSION="2.5.2" log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; } # Run a command with sudo only if not already root (codespace/root containers) maybe_sudo() { if [[ $(id -u) -eq 0 ]]; then "$@"; else sudo "$@"; fi } apt_install() { maybe_sudo apt-get update -qq maybe_sudo apt-get install -y "$@" } ############################################################################### log "1/7 Checking Java (needed for bob.jar)" ############################################################################### if ! command -v java >/dev/null 2>&1; then echo "Java not found. Installing default-jdk..." apt_install default-jdk fi java -version 2>&1 | head -1 ############################################################################### log "2/7 Installing system dependency libxi6 (required by bob's native tools)" ############################################################################### dpkg -s libxi6 >/dev/null 2>&1 || apt_install libxi6 ############################################################################### log "3/7 Downloading bob.jar ${BOB_VERSION}" ############################################################################### if [[ -f "$DEFOLD_DIR/bob.jar" ]] && java -jar "$DEFOLD_DIR/bob.jar" --version 2>/dev/null | grep -q "$BOB_VERSION"; then echo "bob.jar ${BOB_VERSION} already present, skipping download." else curl -sL -o "$DEFOLD_DIR/bob.jar" "$BOB_URL" [[ $(stat -c%s "$DEFOLD_DIR/bob.jar") -gt 1000000 ]] || { echo "ERROR: bob.jar download failed"; exit 1; } fi ############################################################################### log "4/7 Installing Lua 5.1 + Luarocks + testing frameworks" ############################################################################### command -v lua5.1 >/dev/null 2>&1 || apt_install lua5.1 command -v luarocks >/dev/null 2>&1 || apt_install luarocks luarocks show luacheck >/dev/null 2>&1 || maybe_sudo luarocks install luacheck luarocks show busted >/dev/null 2>&1 || maybe_sudo luarocks install busted luarocks show luaunit >/dev/null 2>&1 || maybe_sudo luarocks install luaunit ############################################################################### log "5/7 Installing StyLua formatter" ############################################################################### if ! command -v stylua >/dev/null 2>&1; then TMPZIP="$(mktemp /tmp/stylua.XXXX.zip)" curl -sL -o "$TMPZIP" "https://github.com/JohnnyMorganz/StyLua/releases/download/v${STYLUA_VERSION}/stylua-linux-x86_64.zip" INSTALL_DIR="$(mktemp -d /tmp/stylua_install.XXXX)" unzip -o "$TMPZIP" stylua -d "$INSTALL_DIR" >/dev/null maybe_sudo mv "$INSTALL_DIR/stylua" /usr/local/bin/ rm -rf "$TMPZIP" "$INSTALL_DIR" fi ############################################################################### log "6/7 Installing lua-language-server" ############################################################################### if ! command -v lua-language-server >/dev/null 2>&1; then LLS_URL=$(curl -s https://api.github.com/repos/LuaLS/lua-language-server/releases/latest \ | grep browser_download_url | grep linux-x64 | grep -v musl | cut -d'"' -f4 | head -1) [[ -n "$LLS_URL" ]] || { echo "ERROR: could not resolve lua-language-server download URL"; exit 1; } TARGZ="$(mktemp /tmp/lls.XXXX.tar.gz)" curl -sL -o "$TARGZ" "$LLS_URL" maybe_sudo mkdir -p /opt/lua-language-server maybe_sudo tar -xzf "$TARGZ" -C /opt/lua-language-server maybe_sudo ln -sf /opt/lua-language-server/bin/lua-language-server /usr/local/bin/lua-language-server rm -f "$TARGZ" fi ############################################################################### log "7/7 Verification" ############################################################################### if [[ " $* " == *" --no-verify "* ]]; then echo "Skipping verification." exit 0 fi cd "$DEFOLD_DIR/testproject" FAIL=0 # run_check <label> <command...> — records failure without aborting (set -e safe) run_check() { local label="$1"; shift echo "--- $label ---" if ! "$@"; then FAIL=1 fi } run_check "bob.jar" java --enable-native-access=ALL-UNNAMED -jar ../bob.jar --version run_check "luacheck version" luacheck --version run_check "stylua" stylua --version run_check "lua-language-server" lua-language-server --version run_check "busted tests" busted --lpath="./main/?.lua" spec run_check "lint" luacheck main spec echo "" if [[ $FAIL -eq 0 ]]; then echo -e "\033[1;32mEnvironment fully restored and verified.\033[0m" echo "Next steps:" echo " cd $DEFOLD_DIR/testproject" echo " make bundle # build HTML5 bundle into build_html5/" echo " make serve # serve at http://localhost:8000" else echo -e "\033[1;31mSome checks failed — review output above.\033[0m" >&2 exit 1 fi