Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ember — a 2D game engine for handheld-class hardware

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.

Layout

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

Build & run (Windows)

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

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

Build & run (Linux / Raspberry Pi OS)

sudo apt install build-essential cmake libsdl2-dev
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j4
./build/game/sandbox

WASD/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).

What exists

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 calling run().
  • Window — fixed internal resolution, integer scaling, vsync, software-renderer fallback.
  • Input — action mapping ("move_up", not SDL_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 createFromPixels for 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, rectCollides query. 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::moveEntity gives 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.
  • Teststests/ + 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 property solid_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. applyEntityProps deserializes the common components (position, tag, sprite, collider) onto any entity.
  • Stable lightsLightingSystem lights now live behind unique_ptr (held PointLight* survived only by luck before) plus removeLight.
  • 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.
  • self proxies 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).
  • ember module: 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>.lua automatically. 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: see lib/dialog.lua.
  • Dialog as pure contentlib/dialog.lua is 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) and docs/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 (--root overrides).
  • 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 kernel gpio-keys overlay (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/walk once (Lua define_anims or scene/Tiled "anims" data), switch with self: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_layer layer property. Demo: chasm holes + pillar overhangs.
  • Parallax backgroundsdrawSpriteParallax + 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 transformsself:attach(other, ox, oy); children ride parents, detach safely on parent death.
  • Engine-level text wrapmax_w on ember.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 (live ember.scene.change demo). 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 bindMouse into 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 buttonsember.ui.button{...} -> clicked, with hover and held visual states.
  • Nine-slice panelsember.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.
  • playMusic IS 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 without run() — 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.lua checked other.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_trigger handed scripts a fresh transient proxy for other, so custom fields and identity comparisons silently failed across entities. Script entities now hand out their persistent self proxy everywhere (other == thing works; other.your_flag is 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::windowToInternal handles 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.sh emits dist-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 /saves on 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).

Roadmap

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

Architecture rules (don't break these)

  1. The engine never includes game code. Dependency points one way.
  2. Game code never touches SDL scancodes/events directly — actions only.
  3. All rendering happens in internal-resolution pixel coordinates.
  4. Anything the editor will need to edit must eventually be data, not code.

About

A 2D engine built for the future.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages