Category Archives: Game Development

Arcade game screen showing SCORE: 125,480, LEVEL: 3, LIFE, BOMBS, and SHIELD: 88%.

Creating Doppler Sound Effects in Vertical Scrolling Games

Lessons from building the obstacle pass-by effect in a vertical scrolling game (Defold), and how to apply the same technique in any engine.


The problem

Your player races forward. Cars, trucks, hazards scroll toward them from the top of the screen, scream past on either side, and vanish off the bottom. Right now they’re silent — or worse, every one of them plays the same loop at full volume, producing a wall of noise.

You want the feeling of speed: an approaching engine that rises in pitch, flips into a whoosh at the moment of the pass, and drops away behind you. A real Doppler shift.

The good news: you don’t need physics simulation, DSP, or a middleware audio programmer. You need about four small functions and one design decision.

The key insight: pitch follows velocity, volume follows distance

A common mistake is to derive everything from distance. Don’t. If pitch tracks distance, cars sound like they’re shifting gears as they approach instead of holding a steady tone.

The trick used by Mario Kart (and documented by Audiokinetic’s Wwise team) is:

  • Pitch = relative velocity. While the object closes on the listener, hold the pitch high. After it passes, hold it low. Slide between the two only in a narrow band around the pass point.
  • Volume = live distance. Full volume inside a small radius, smooth falloff out to a “hear bubble” edge.
  • Pan = lateral offset. Left/right from horizontal separation.

Because your objects scroll at a known speed, “relative velocity” collapses to a single number: the sign and magnitude of object_y - listener_y. That’s the whole effect.

-- Pitch: held high while closing, smooth flip at the pass, held low after.
function pitch_ratio(dy, cfg)
    local hi = cfg.pitch_approach   -- e.g. 1.03
    local lo = cfg.pitch_recede     -- e.g. 0.95
    local cross = cfg.cross_span    -- whoosh width in px, e.g. 72
    if dy >= cross then return hi end
    if dy <= -cross then return lo end
    local t = (cross - dy) / (2 * cross)
    t = t * t * (3 - 2 * t)         -- smoothstep: no gear-step click
    return hi + (lo - hi) * t
end

Note how narrow the ratio range is: 1.03 → 0.95. Wide ranges make engine loops sound like gear changes. Subtle is convincing.

Volume: arcade proximity, not inverse-square

Real-world falloff sounds wrong in games. Use the classic arcade curve:

  1. Min-distance sphere — full volume inside a radius (e.g. 55 px).
  2. Quadratic rolloff beyond it: (1 - t)² where t is normalized distance to the bubble edge.
  3. Extra lateral weighting so a car one lane over stays quiet even when level with you:
local lat = 1 / (1 + (dx / lane_width)^2)
gain = radial * (0.28 + 0.72 * lat)

That last term is what makes two lanes away “a whisper” instead of “half volume” — critical when lanes are only ~170 px apart.

Pan and the camera-shake trap

Pan is trivially clamp(dx / pan_width, -1, 1).

But here’s a bug that cost us a debugging round: if you sample positions in world space, any screen shake modulates gain and pan every frame — the audio wobbles whenever the camera jolts. Sample the object’s position relative to its layout parent (local space) for the lateral axis, and world/screen space only for the vertical axis that actually scrolls.

Voice management: nearest-N wins

You can’t play a loop per spawned object. Keep a mixer tick (once per frame) that:

  1. Collects live objects inside the hear bubble.
  2. Sorts them: still-on-screen first, then nearest.
  3. Keeps only MAX_VOICES (3 works well).
  4. Mutes anything that dropped out; starts anything new.

Two lifecycle details that matter:

  • Let finished voices linger. An object culled off-screen should keep playing until its fade completes, otherwise the sound chops mid-note. Extend the recede window by a fraction (KEEP_FRAC ≈ 1.15) of travel time past the cull line.
  • Never set gain to exactly zero mid-play. Most engines (Defold included) drop zero-gain voices entirely, and later gain changes are then silent. Clamp to a tiny floor (~0.002) and use explicit stop for real mutes.

One voice per instance, not shared slots

Tempting shortcut: a pool of N loop players, reassigned each frame. Don’t. When the pool reassigns, one car steals another’s voice mid-pass and the mix stutters. Give each spawner its own component/channel and address it directly — memory is cheap, glitches are not.

Make late levels stay bright

If your game speeds up over time, a fixed pitch curve makes fast passes feel identical to slow ones. Scale the held pitch ends with scroll pace:

local pace = clamp(scroll_px_per_sec / reference_speed, 1, max_pace)
hi = hi + (pace - 1) * lift_per_pace   -- +0.08 per extra 1x start speed
lo = lo - (pace - 1) * drop_per_pace   -- +0.025 → stronger whoosh contrast

Cap both ends (e.g. 1.28 / 0.88). Now deep runs genuinely sound faster.

Architecture checklist

Whatever your engine, structure it like this — it keeps the math testable and the system reskinnable:

LayerResponsibilityTestable?
Pure math modulepitch/gain/pan/phase curves, voice selectionYes — plain unit tests
Mixer/tickper-frame orchestration, lifecycle, loggingMostly
Transportactual play/stop/pause calls, engine quirksNo
Snapshotmaps game state to {id, x, y, lane} recordsYes

Keep all tunables in one config table with guards in tests (e.g. “recede window < hear window”, “whoosh width ≤ 100 px”). Future-you will tune this constantly; a config table plus validation beats hunting magic numbers.

Tuning cheat sheet

Start here, adjust by ear:

hear_bubble      = 520 px     approach window
min_dist         = 55 px      full-volume sphere
max_dist         = 520 px     silent at edge
pitch_approach   = 1.03       held while closing
pitch_recede     = 0.95       held after passing
whoosh_width     = 72 px      (or 0.12 s of travel, whichever is larger)
pan_width        = 2 x lane spacing
max_voices       = 3
lane_detune      = +/-0.4 %   separates side-by-side sources

Summary

The “Doppler” effect is three decoupled curves — pitch from velocity sign, volume from 2D distance, pan from lateral offset — driven once per frame by a nearest-N voice mixer. No physics, no DSP. The hard parts aren’t the math; they’re the engineering around it: local-vs-world coordinates, zero-gain voice drops, lingering fades, and per-instance voices. Get those right and a stock engine loop becomes a convincing rush of traffic.

Technique references: Mario Kart-style relative-velocity pitch (Lilley/Scruffy write-up), Audiokinetic Wwise Doppler blog, arcade min/max distance falloff (GameMaker / shmup tradition).

Defold Game architecture diagram connecting game engine, backend, cloud, database, and live operations

Building Defold HTML5 Games in GitHub Codespaces — A Complete Guide

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 toolbob.jar is 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:

  1. Rotate the game object ("."), not the component ("#label") — component rotation isn’t reliably animatable.
  2. 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.
  3. 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

GotchaFix
Platform js-web not supportedUse wasm-web
libXi.so.6: cannot open shared object fileapt-get install libxi6
Native access errors on modern JDKjava --enable-native-access=ALL-UNNAMED
Changes don’t appear after rebuildBundle must include clean build — stale cache lies
Error shows truncated path (main.collectio)Display quirk; not the real problem
Label won’t compileNeeds text, size (Vector4!), font, AND material
Blocky font renderingDF font ↔ label-df.material, bitmap ↔ label-fnt.material
Spin animation does nothingAnimate "." not "#component" for rotation
Rotation swings off-screenPivot = game object position; center the object, zero the component offset
.luacheckrc syntax errorIt’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

Building a Scalable 2D Game Scene Architecture: From Back to Front

Creating a clean, scalable scene architecture for a 2D game is more than just organizing visuals—it’s about building a system that supports gameplay, UI, effects, and camera logic in a way that’s intuitive and future-proof. In this post, we’ll walk through a layered architecture that separates concerns, supports depth-based gameplay, and keeps your UI crisp and your effects polished.

Whether you’re building a vertical shooter, a platformer, or a retro arcade game, this structure gives you the flexibility to scale without chaos.

🧱 Scene Graph Overview

At the core is root_scene, which contains all visual and logical layers. These layers are organized from background to foreground, with clear roles and transformation rules.

root_scene
├── game_group                            # Camera-controlled gameplay container
│   ├── hidden_group                     # Off-screen/inactive entities (object pooling)
│   ├── background_group                 # Default background layer + depth container
│   │   ├── background_bottom_group      # Farthest background visuals (sky, base)
│   │   ├── background_mid_group         # Parallax mid-layers, distant FX
│   │   ├── background_top_group         # Closest background visuals
│   ├── objects_group                    # Default gameplay layer + depth container
│   │   ├── objects_depth_bottom_group   # Farthest gameplay entities
│   │   ├── objects_depth_mid_group      # Primary gameplay layer (player, pickups)
│   │   ├── objects_depth_top_group      # Foreground gameplay entities
│   ├── foreground_group                 # Foreground visuals + depth container
│   │   ├── foreground_bottom_group      # Farthest foreground elements
│   │   ├── foreground_mid_group         # Mid-range foreground visuals
│   │   ├── foreground_top_group         # Closest foreground overlays
│   ├── visual_fx_group                  # Explosions, particles, transient visuals
│
├── hud_group                            # Score, gauges, indicators (screen-anchored)
├── menu_group                           # Title screen, credits (non-blocking UI)
├── modal_group                          # Pause, game over, dialogs (blocking overlays)
├── debug_group                          # Dev-only overlays, performance HUD
├── screen_fx_group                      # CRT shader, bloom, vignette (post-processing)
  

🧠 Layer Roles & Camera Behavior

Each layer has a defined purpose and relationship with the camera. Gameplay and visual layers move with the camera, while UI and post-processing layers remain fixed or apply globally. Note that background and foreground groups can also be used in menu layers along with menu group.

LayerPurposeTransforms with Camera
game_groupMaster container for gameplay layers✅ Yes
hidden_groupObject pooling, inactive/off-screen entities✅ Yes
background_groupDefault background layer✅ Yes
background_bottom_groupFarthest background visuals (sky, base)✅ Yes
background_mid_groupParallax mid-layers, distant FX✅ Yes
background_top_groupClosest background visuals✅ Yes
objects_groupDefault gameplay layer✅ Yes
objects_depth_bottom_groupFarthest gameplay entities✅ Yes
objects_depth_mid_groupCore gameplay layer (player, enemies, pickups)✅ Yes
objects_depth_top_groupForeground gameplay entities✅ Yes
foreground_groupDefault foreground layer✅ Yes
foreground_bottom_groupFarthest foreground visuals✅ Yes
foreground_mid_groupMid-range foreground visuals✅ Yes
foreground_top_groupClosest foreground overlays✅ Yes
visual_fx_groupExplosions, particles, screen shake✅ Yes
hud_groupScore, gauges, indicators❌ No
menu_groupTitle screen, credits❌ No
modal_groupPause, game over, dialogs❌ No
debug_groupDev overlays, performance HUD❌ No
screen_fx_groupPost-processing shaders (CRT, bloom, vignette)❌ No (global)

🧰 API Naming Conventions

To keep things clean and predictable, each layer has dedicated adders and getters. This ensures encapsulation and avoids direct manipulation of scene graph internals.

🔧 Adders

python

add_to_hidden_group(obj)
add_to_background_group(obj)
add_to_background_bottom_group(obj)
add_to_background_mid_group(obj)
add_to_background_top_group(obj)

add_to_objects_group(obj)
add_to_objects_depth_bottom_group(obj)
add_to_objects_depth_mid_group(obj)
add_to_objects_depth_top_group(obj)

add_to_foreground_group(obj)
add_to_foreground_bottom_group(obj)
add_to_foreground_mid_group(obj)
add_to_foreground_top_group(obj)

add_to_visual_fx_group(obj)
add_to_hud_group(obj)
add_to_menu_group(obj)
add_to_modal_group(obj)
add_to_debug_group(obj)
add_to_screen_fx_group(obj)
  

🔍 Getters

python

get_hidden_group()
get_background_group()
get_background_bottom_group()
get_background_mid_group()
get_background_top_group()

get_objects_group()
get_objects_depth_bottom_group()
get_objects_depth_mid_group()
get_objects_depth_top_group()

get_foreground_group()
get_foreground_bottom_group()
get_foreground_mid_group()
get_foreground_top_group()

get_visual_fx_group()
get_hud_group()
get_menu_group()
get_modal_group()
get_debug_group()
get_screen_fx_group()
  

📏 Ownership & Layering Rules

To maintain clarity and prevent misuse, each type of entity has a designated home:

  • Gameplay entitiesobjects_group or one of its depth layers
  • Background visualsbackground_group or its depth layers
  • Foreground visualsforeground_group or its depth layers
  • HUD elementshud_group
  • Menusmenu_group
  • Blocking overlaysmodal_group
  • Debug toolsdebug_group only
  • Visual effectsvisual_fx_group
  • Post-processing shadersscreen_fx_group
  • Camera transformations → applied only to game_group and its children

🚫 Layering Constraints

To avoid rendering chaos and maintain performance:

  • ❌ No toFront() calls in gameplay layers
  • ✅ UI systems may adjust local order within their own group
  • ✅ Depth layers maintain internal z-ordering

✅ Benefits of This Architecture

  • Clear separation of concerns: Each layer has a distinct visual and logical role
  • Scalable and maintainable: Easy to audit, extend, and debug
  • Camera-friendly: game_group isolates gameplay transformations from UI
  • Depth flexibility: objects_group, background_group, and foreground_group support layered interactions
  • UI integrity: HUD and modals remain crisp and unaffected by zoom/shake
  • Post-processing polish: screen_fx_group applies final visual effects globally

🧪 Final Thoughts

This architecture isn’t just a technical blueprint—it’s a philosophy of clarity. By separating gameplay, background, foreground, UI, and effects into well-defined layers, you empower your team to build faster, debug smarter, and scale confidently.

If you’re working on a game and want help adapting this structure to your engine or genre, I’d love to collaborate. Let’s build something beautiful.

How to save game scores in Corona SDK by writing to a file.

The scope of this tutorial will be to record and store the highest score achieved by a player in a game. This is done by:

  • Creating a file with a score of zero where the file does not exists.
  • When a player dies:
    • The previous score, i.e. value within the file, is assigned to a variable.
    • The player’s new score is assigned to a variable.
  • The two variables are compared.
  • If the previous score is higher nothing is changed.
  • If the previous score is lower the new score will be written to the file in its place.

Note:

No UI is provided as part of the tutorial as corona sdk often changes the manner in which objects are displayed, meaning the UI could break with future corona sdk versions. We will be working from the simulator output window alone.

For security reasons, you are not allowed to write files in the system.ResourceDirectory (the directory where the application is stored). You must specify either system.DocumentsDirectory, system.TemporaryDirectory, or system.CachesDirectory in the system.pathForFile() function when opening the file for writing. Read move about this here.

Below is a table describing when and where each directory should be used.

systemDirectoriesTo use this tutorial create a folder containing a main.lua file.
Paste the code below into the file and save.
Open the file with Corona SDK and the score.txt file will be created and populated with a score of zero.

Play around with the Player Score variable:
newScore = 99

Enter a higher score and it will overwrite what currently exists in the file.

Enter a lower score and nothing will be changed.

-- main.lua

local path = system.pathForFile( "score.txt", system.DocumentsDirectory )

deleteFile = function()
 local result, reason = os.remove(path) 
	if result then
		print( "File removed" )
	else
		print( "File does not exist", reason )  --> score.txt: No such file or directory
	end
end

--[[ 
Uncomment below to remove file
--]]

--deleteFile()

-- Player Score

newScore = 99

-- io.open opens a file at path. returns nil if no file found
-- fh short for file handle
-- "r" is the read instruction
local fh, reason = io.open( path, "r" )

if fh then
    -- Read all contents of file into a variable oldScore
	-- This will be the previous score
	-- To read file content as number use "*number"
	-- To read file content as text use "*a"
	-- "\n" new line
    local oldScore = fh:read( "*number" )
    print( "Old contents of " .. path .. "\n" .. oldScore )
	
	if oldScore < newScore then
		-- re-opening the file in "w+" mode will erase all previous data stored in the file
		-- in the comments below is a table listing all the file mode types
		fh = io.open( path, "w+" )
		-- Set the score to the player's new score.
		fh:write( newScore )
		print( "New contents of " .. path .. "\n" .. newScore )
	end	
else
	-- Error logic
    print( "Reason open failed: " .. reason )  -- display failure message in terminal

    -- create file because it doesn't exist yet
    fh = io.open( path, "w" )

    if fh then
        print( "Created file" )
    else
        print( "Create file failed!" )
    end
	
	-- Set the score to zero.
    fh:write( 0 )

end

io.close( fh )

--[[
The various file modes are listed in the following table:

"r"	Read-only mode and is the default mode where an existing file is opened.
"w"	Write enabled mode that overwites existing file or creates a new file.
"a"	Append mode that opens an existing file or a creates a new file for appending.
"r+" Read and write mode for an existing file.
"w+" All existing data is removed if file exists or new file is created with read write permissions.
"a+" Append mode with read mode enabled that opens an existing file or creates a new file.

]]--

See the lua online book’s I/O library section for more information on working with files.