Working name. Picked because the lighting system is the star feature. Rename freely.
C++17 on SDL2. The engine is a library (libember); games and the future
editor are applications that link it. Internal resolution is fixed (320×240
by default) and scaled to the display — desktop window or handheld screen,
same build.
ember/
├── engine/ libember — the engine core
│ ├── include/ember/ public headers (the engine's API surface)
│ └── src/ implementation
├── game/ sandbox game / test harness (links libember)
├── editor/ reserved: editor app (libember + Dear ImGui)
└── CMakeLists.txt
Recommended: WSL2 (your handheld target is Linux anyway, so this is
the verified path with zero translation): wsl --install -d Ubuntu,
then follow the Linux instructions below inside Ubuntu — the game window
and audio appear on the Windows desktop via WSLg.
Native .exe: MSYS2 (https://www.msys2.org). In the MSYS2 UCRT64 shell:
pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake mingw-w64-ucrt-x86_64-ninja mingw-w64-ucrt-x86_64-SDL2
cd /c/path/to/ember # Windows drives mount under /c, /d, ...
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build # should be 6/6
./build/game/sandbox.exeTo launch the .exe outside the MSYS2 shell (Explorer, a shortcut), copy
SDL2.dll from C:\msys64\ucrt64\bin\ next to the executable.
The POSIX call sites (chdir/mkdir) are guarded for Windows; this path is
code-ready but awaiting a real-machine verification pass.
sudo apt install build-essential cmake libsdl2-dev
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j4
./build/game/sandboxWASD/arrows to move, SPACE to dash, ESC to quit.
On the Pi handheld, set config.window.fullscreen = true in main.cpp
(later: this comes from a config file, not code).
Phase 1 — platform layer:
- Engine — init/shutdown, fixed-timestep loop (60 Hz updates,
"Fix Your Timestep" accumulator pattern), render interpolation alpha,
scene stack (push/pop/replace).
stepFrame()is public so the editor can drive the loop itself instead of callingrun(). - Window — fixed internal resolution, integer scaling, vsync, software-renderer fallback.
- Input — action mapping (
"move_up", notSDL_SCANCODE_W). Keyboard now; handheld GPIO buttons become another binding source later. - Scene — the engine↔game contract:
update(dt)fixed-rate,render(renderer, alpha)per-frame.
Phase 2 — renderer & lighting:
- Texture — PNG/JPG loading via vendored stb_image (still zero
external deps beyond SDL2), plus
createFromPixelsfor procedural art and editor tooling. Move-only RAII. - Renderer2D — immediate-mode API over a sorted command queue:
draw in any order, layers resolve at
end(). Camera transform applied in one place; off-screen commands culled. SDL batches GPU-side. - Camera2D / SpriteSheet — follow + world clamp; grid-frame atlas math.
- LightingSystem — the star feature. Ambient color + colored point lights with organic flicker, rendered additively to a 320×240 lightmap, multiply-blended over the scene. One render-target pass + one quad per light: cheap enough for the Pi Zero 2W, looks like Terraria at night.
The sandbox is now a torch-lit dungeon: WASD/arrows move, L toggles
lighting (compare!), ESC quits. All art is procedural — no asset files yet.
./sandbox --screenshot out.bmp runs 90 frames headlessly and dumps a
frame (used for automated visual verification).
Phase 3 — core systems:
- Assets — named texture cache with the magenta missing-texture fallback. Names (not paths) are what entities and tilemaps reference — the indirection Phase 4's data files require.
- World / Entity — composition-based entities (SpriteComp, ColliderComp + behavior via small subclasses). World handles update, velocity integration with collision, trigger overlaps (onTrigger), wall-hit callbacks (onBlocked), deferred spawn, and dead-entity removal.
- Tilemap — tile grid + SpriteSheet tileset, per-tile-ID solidity,
visible-range rendering,
rectCollidesquery. Out-of-bounds is always solid. Phase 4's Tiled importer will emit exactly this structure. - Collision — AABB primitives; axis-separated sub-pixel movement
in
World::moveEntitygives wall-sliding for free. - Audio — software mixer on the raw SDL device (no SDL_mixer): 16 voices, WAV loading, procedural sounds, per-voice volume + looping. Soft-fails to silence on speakerless setups.
- Tests —
tests/+ CTest. Headless logic tests for AABB, tilemap solidity, movement/sliding, triggers, lifecycle. Already caught one real bug (out-of-bounds solidity depended on tileset numbering). Run:ctest --test-dir build.
The sandbox is now a game: collect all 5 gems (cyan glow marks them), walls are solid, pickups chime (a synthesized C-E-G arpeggio — still zero asset files), HUD tracks progress above the lighting.
Phase 4 — the data-driven layer:
- Scene files (
assets/scenes/*.scene.json, comments allowed) declare assets to load, input bindings (SDL key names → actions), lighting, the map, and inline entities. Change content without recompiling. - Tiled import —
.tmj(Tiled's JSON export; preferred over .tmx to avoid an XML dependency). First tile layer fills the Tilemap; tileset name maps to an Assets texture name; map propertysolid_ids(CSV) marks blocking tiles; object layers spawn entities by class through: - EntityFactory — the game registers type names → creators once;
scene files and Tiled maps spawn them freely. This registry is the
future editor's entity palette.
applyEntityPropsdeserializes the common components (position, tag, sprite, collider) onto any entity. - Stable lights —
LightingSystemlights now live behind unique_ptr (held PointLight* survived only by luck before) plusremoveLight. - tools/gen_assets.py regenerates all sandbox assets (PNGs, WAV, .tmj) deterministically. Replace with hand-authored files freely.
- Vendored nlohmann/json v3.11.3.
Two bugs found and fixed this phase, both with regression tests:
light-pointer invalidation on vector growth, and a dangling-temporary
crash from iterating .items() on a .value() copy in the scene loader.
The sandbox's C++ is now ONLY entity behaviors + "load this scene".
Run from the project root: ./build/game/sandbox.
Phase 5 — Lua scripting:
- Vendored Lua 5.4.7 compiled into the engine — still zero system dependencies beyond SDL2. Sandboxed: base/math/string/table libs only (no io, no os) — scripts are game content, not system administration.
- Behavior scripts (
assets/scripts/*.lua) return a table of callbacks:init(self, props),update(self, dt),on_trigger(self, other),on_blocked(self, x_axis). All optional. selfproxies the entity: read/write x, y, vx, vy, tag, alive;self:set_sprite{...}/self:set_collider{...}; store any custom fields on it (self.timer = 0— backed by a Lua uservalue table).embermodule:log,input.is_down/just_pressed,audio.play,lights.add/remove(handles expose x/y/radius/intensity),world.count/find_first. Lifetime rule: proxies from on_trigger or find_first are valid for the current frame — keep tags, not references.- Factory fallback: entity types with no C++ registration resolve to
assets/scripts/<type>.luaautomatically. C++ registrations win, so native hot paths remain available. - Script errors never crash: syntax errors, runtime errors, and non-table behaviors are logged and contained (same philosophy as the missing-texture checkerboard). Covered by tests.
gem.lua, torch.lua, and player.lua now define the entire sandbox game —
game/src/main.cpp contains no entity classes at all. Edit a script,
relaunch, no recompile. Three test suites, 49 checks.
Phase 5.5 — text, UI & dialog:
- Font — bitmap fonts on a grid. A public-domain 8x8 font is EMBEDDED
in the engine (baked to a texture at runtime, white-on-transparent so
draw-time tint gives any color): text works with zero asset files.
Custom fonts: any Assets texture +
initFromGrid(name, w, h, cols). - Renderer2D::drawText — world- or screen-space, newline-aware, tinted.
- UISystem — per-frame command buffer (panels, bars with clamped values + borders, aligned text). Scripts DESCRIBE the UI during update(); the scene draws the buffer after the lighting composite, so UI is never swallowed by darkness.
- ember.ui in Lua:
ember.ui.text{x=,y=,text=,align=,color=,scale=},ember.ui.bar{x=,y=,w=,h=,value=,fg=,bg=},ember.ui.panel{...}. - Sandboxed require() — pure-Lua modules from
assets/scripts/(C loaders stripped, io still blocked — tested). Shared game libraries are now possible: seelib/dialog.lua. - Dialog as pure content —
lib/dialog.luais a require()-able conversation system: speaker name, paged lines, [E] to advance, player freezes while talking. The engine has no dialog code at all — proof the mechanism/content split works.
The sandbox now has an NPC (the Hermit) with a three-line conversation, HP/stamina bars, and a live gem counter — all of it scripted. Four test suites, 64 checks.
Phase 6 — completeness batch (the market-standard checklist —
see docs/CHECKLIST.md for the full audit):
- Frame animation (
self:play_anim), particles (bursts + emitters), camera shake, collision layers/masks, raycasting - Gamepad support: buttons + analog axes with deadzones, hot-plug, bound in scene data alongside keyboard
- Audio: stereo panning, sfx/music buses, per-voice fades (crossfading), scene-data background music
- DataScene in the engine: a game executable is now ~10 lines.
Camera follow, music, F3 debug overlay (FPS/counts/collider
outlines), F5 hot reload, and
ember.scene.change()transitions - Save/load: sanitized JSON slots from Lua
- Lua stdlib:
lib/timer,lib/tween(easings),lib/fsm,lib/astar(pathfinding over the live tilemap) docs/SCRIPTING.md(full API) anddocs/CHECKLIST.md(feature audit with explicit non-goals and their escape hatches)
Five test suites, 78 checks.
Audit pass (Phase 6.6): five bugs found by adversarial review and fixed with regression coverage — most seriously a use-after-free when a scene replaced itself from inside its own update() (F5 / scene.change path; scene swaps are now deferred to a safe point), plus a destruction- order hazard between ScriptHost and World, a double-rendered UI buffer in the debug overlay, out-of-range glyph indexing on non-ASCII bytes, and a signed-char isalnum. Verified by: six test suites (85 checks) on x86 AND ARM64-under-QEMU; full suite + complete game + a 448-consecutive-scene- swap torture run all clean under AddressSanitizer/UBSan with leak detection; corrupted/missing scene files fail gracefully; cppcheck clean on engine sources; cross-architecture frames still bit-identical.
Phase 6.5 — real-world hardening:
- Asset-root resolution: the binary finds
assets/relative to the executable — launch it from anywhere (--rootoverrides). ember.config.json: resolution, scale, fullscreen, vsync, update rate, start scene — edit a text file, not code. Handheld = flip"fullscreen": true.- F3 overlay now shows frame timing (update/render ms) for budget work on the Pi.
tools/package.sh: tests as a gate, then a shippable tar.gz (binary + assets + config), ~620 KB.docs/HANDHELD.md: GPIO buttons via the kernelgpio-keysoverlay (zero engine code — they arrive as keyboard input), wiring, boot-to-game systemd unit, performance levers.- ARM64 verified: cross-toolchain file included; all 78 checks pass as ARM binaries under QEMU, and the ARM build renders bit-identical frames to x86 (0/691,200 pixels differ) — deterministic across architectures. This pass also caught and fixed a real HUD regression the x86 runs had masked.
Phase 7 — rendering & world completeness (shaped by the first real playtest):
- Named animation sets — declare
idle/walkonce (Luadefine_animsor scene/Tiled"anims"data), switch withself:play("walk"); per-frame calls are no-op-safe. - Multi-layer tilemaps — per-layer render depth (foreground
overhangs draw OVER entities), solidity across layers, Tiled
render_layerlayer property. Demo: chasm holes + pillar overhangs. - Parallax backgrounds —
drawSpriteParallax+ scene-data"backgrounds"with two-axis tiling; cave depth visible through the demo's chasm. - Camera zoom — 0.25–4×, Z toggles in the sandbox; culling and lighting are zoom-aware; UI stays screen-sized.
- Parent/child transforms —
self:attach(other, ox, oy); children ride parents, detach safely on parent death. - Engine-level text wrap —
max_wonember.ui.text(any grid font); the dialog library now uses it instead of Lua-side wrapping. set_sprite{sheet=,frame=}— static frames without the animation system.
Seven test suites, 110 checks.
Phase 8 — platformer physics (the features "people judge engines by", per the market checklist):
- One-way platforms — tiles AND entities (moving platforms work); land from above, pass from below/side; drop-through with down+jump.
- 45-degree slopes — '/' and '' tiles, resolved as a foot-center vertical constraint (smooth ascent/descent on a tile grid), with a step-up assist where slopes top out against solid blocks.
World::onGround/self:on_ground()— solid, one-way, or slope support, the controller's ground truth.lib/platformer.lua— gravity, variable-height jumps, coyote time, jump buffering, drop-through, in ~80 lines of pure Lua.- A second scene: side-view level (
platformer.scene.json) with a slope hill and a one-way plank staircase — press Tab in the dungeon to hot-switch into it and back (liveember.scene.changedemo). Same engine, same assets, different genre. - Tiled properties:
oneway_ids,slope_right_ids,slope_left_ids.
Eight test suites, 125 checks.
Phase 9 — mouse + UI toolkit (the last engine-tier checklist gap, and deliberately the editor's foundation):
- Mouse input — position auto-converted to internal-resolution
coords through the window scaling, three buttons with press/release
edge detection, wheel, and
bindMouseinto the action system. ember.camera.to_world(mx, my)— cursor to world position; the demo spawns spark bursts wherever you click in the dungeon.- Anchored layouts — nine anchors; UI survives any internal resolution ("4px in from the bottom-right" instead of hardcoded x).
- Immediate-mode buttons —
ember.ui.button{...} -> clicked, with hover and held visual states. - Nine-slice panels —
ember.ui.panel9: crisp corners, stretched edges, at any size, from one small texture. - In-game pause menus in both scenes (P or Start): nine-slice panel, mouse-clickable Resume / scene-switch / Quit. The first UI in Ember you can click.
Nine test suites, 151 checks.
Phase 10 — streamed music (OGG Vorbis via vendored stb_vorbis, public domain, zero new system deps):
- True streaming: tracks decode in chunks inside the mixer — a 3-minute song costs ~3MB of disk and a few KB of buffers, never tens of MB of PCM. The demo's 16-second composed cave theme is 55KB.
playMusicIS the crossfade: starting a track fades the current one out over the same window.stopMusic(fade)for outros.- Format tolerance: mono sources duplicate to stereo; mismatched sample rates get nearest-frame resampling (retro-appropriate).
- Scene data:
"music": {"file": "...ogg", "volume":, "fade":}streams; the wav"sound"path remains for chiptune-scale loops. - The platformer scene now streams a composed theme (bass, pad, pentatonic arpeggio — hear it via Tab); the dungeon keeps its wav drone, proving both paths.
- Verified: 21 streaming checks on x86 AND ARM64-under-QEMU; live mixer-callback decoding clean under ASan/UBSan with leak detection.
Ten test suites, 172 checks.
v0.10.1 — playtest hotfix (three real bugs, found because a human finally pressed a movement key in the dungeon):
- The every-tile-solid bug: Phase 7 marked tile id 0 solid (chasm holes) while multi-layer maps treat a tile as solid if ANY layer says so — and upper layers are mostly id 0. The whole dungeon became a wall; the player froze at spawn. Fix: id 0 means "no tile" and can never be solid/one-way/slope (warned no-op); the chasm now uses an explicit transparent "void" tile (id 8, solid). Regression-tested.
- Phase 9 mouse wiring never shipped: the patch that wires SDL mouse events into Input silently failed to apply, so clicks did nothing in real builds (tests passed — they injected below the event layer). Now wired and covered by a pumpEvents-level regression test.
stepFrame()now works standalone withoutrun()— the editor API contract, and what diagnostic harnesses assume.
Lessons encoded in the tests: every automated patch asserts it matched, and input/movement now have end-to-end coverage through real SDL events on the shipped map.
v0.10.2 — playtest fix #2 (platformer gems ignored the hero):
gem.luacheckedother.tag == "player"; the side-view hero is tagged "hero". Gems now check a capability —other.collects_gems— which both characters declare. Identity checks couple content; capability flags compose.- That pattern surfaced an engine gap, now fixed:
on_triggerhanded scripts a fresh transient proxy forother, so custom fields and identity comparisons silently failed across entities. Script entities now hand out their persistent self proxy everywhere (other == thingworks;other.your_flagis visible). Regression-tested, plus an end-to-end autodrive collection check.
v0.10.3 — playtest fix #3 (low FPS + clicks landing far away):
- Render-to-target architecture: the scene now draws 1:1 into a 320x240 target texture, then ONE integer-scaled blit to the window. Previously SDL's logical-size scaling made software renderers (WSL fallbacks, the handheld's KMSDRM) rasterize every draw at window resolution — 9x the pixel work at 3x scale. Render time dropped 44% even in the small headless case; far more on real software backends.
- Tap-position fix: mouse position was only taken from MOTION events; touchscreen taps, pens, and trackpad-taps deliver clicks at a new position with NO prior motion — effects spawned wherever the cursor last moved. Button events now update position themselves.
- Coordinate math is now ours:
Window::windowToInternalhandles integer scaling + letterboxing exactly on every platform (tested: exact-multiple, letterboxed, oversized, undersized windows). - Frame limiter: with vsync off (or broken — common under WSLg),
the loop no longer busy-spins;
"max_fps"in ember.config.json, 0 = auto. The startup log now prints the renderer backend and vsync state — if it says SOFTWARE, that's why frames are expensive.
v0.10.4 — the tileset that worked by accident (caught from a playtest LOG LINE, not a visible symptom):
- The v0.10.1 void-tile fix patched the map but its tileset half silently failed to apply — three releases shipped a 7-frame texture with maps referencing frame 8. SDL clipped the out-of-bounds source rect to nothing, which LOOKS exactly like a transparent void. Fixed the generator (now 8 frames, 128x16, void verified fully transparent) and hardened the renderer: out-of-bounds tile frames now warn once and skip deliberately instead of rendering luck.
- Performance epilogue: post-v0.10.3 playtest numbers on real hardware — 60 FPS locked, update 0.07ms, render 0.74ms on an accelerated backend. The engine uses ~1.3% of its frame budget.
Phase 11 — the web export (the last engine-tier checklist item):
./tools/build_web.shemitsdist-web/: ONE static page, ~2 MB total, containing the complete sandbox — both scenes, lighting, particles, the Lua runtime, streamed OGG music, persistent saves.- The architecture paid off on schedule: browsers own the frame loop
(requestAnimationFrame, no blocking), and
stepFrame()— public since Phase 1 for exactly this — is simply registered as the callback.run()is never called on the web. - Saves mount
/saveson IndexedDB: pulled in before boot, flushed after every write. A tab refresh keeps your progress. - Audio respects browser autoplay policy (resumes on first input); assets pack into a preload bundle; gamepads ride the browser API.
- Verified: engine logic (world, platformer physics, full Lua scripting) compiled to WebAssembly and ran under node — 52 checks green. Visual/browser verification is the playtester's privilege.
- Ember now runs identically from one codebase on x86, ARM64, and WebAssembly.
Phase 12 — the editor (the post-engine era begins):
ember_editor: a Dear ImGui app hosting the LIVE engine — the game renders into its internal target (the v0.10.3 architecture) and that texture is the editor viewport. Play/Pause/Step toolbar: playtest and edit in the same window.- Click-select with outline overlay, drag-to-move, Inspector editing
(position/velocity/tag/delete), entity placement from a palette
scanned off
assets/scripts/, tile painting straight onto the live map with layer selection and erase. - Save Map patches the original .tmj — tiles from the live Tilemap, objects from the spawn registry joined with the live world. Custom properties survive; play-session deaths don't get saved; scene-file entities stay out of the map file (spawn-origin tracking).
- Engine additions:
initEmbedded(host-owned window/renderer),renderFrame(view without update),currentScene, world iteration, tile get/set, and the factory spawn registry. 25 new checks (212 total, 11 suites).
Shipped in 1.0 (see docs/CHECKLIST.md for the full feature audit):
Phases 2–7 — core engine, lighting, audio, scripting, Tiled maps✅Phases 8–10 — platformer physics, mouse/UI toolkit, OGG streaming✅Phase 11 — Emscripten web export✅ (tools/build_web.sh,docs/WEB.md)Phase 12 — Dear ImGui editor hosting libember✅ (docs/EDITOR.md)
Still open (post-1.0):
- Editor iteration: scene-JSON editing, collider handles, multi-select, undo/redo
- Open-world scaling batch: entity activation radius, spatial-hash triggers,
chunked/streamed maps (see
docs/EDITOR_BRIEF.md§5) - Queued smalls: grandparent transform chains, runtime rebind API, touch input, linear audio resampling
- Hardening: adversarial Lua sandbox audit before advertising mod safety
- The engine never includes game code. Dependency points one way.
- Game code never touches SDL scancodes/events directly — actions only.
- All rendering happens in internal-resolution pixel coordinates.
- Anything the editor will need to edit must eventually be data, not code.