File size: 11,281 Bytes
3ce1a40 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | # UNIFY
UNIFY is a 2D game engine runtime written in C++17, with Lua gameplay, a CUDA-like kernel compiler that
targets its own WASM runtime, and interchangeable platform backends. `game/` holds a small platformer
built on it, which proves the runtime end to end.
```
UNIFY where
βββ renderer engine/render RenderDevice, Renderer2D (sort + batch), camera, atlas, QOI/PNG, bitmap font
βββ physics engine/physics rigid bodies, SAP broadphase, SAT/clip manifolds, sequential impulses, CCD, islands
βββ audio engine/audio mixer, buses, voices, WAV decoder, streaming ring buffer, device thread
βββ input engine/input timestamped event queue β per-tick InputFrame, actions/axes, buffering
βββ animation engine/animation sprite clips, keyframe tracks, loop/once/ping-pong, crossfade blending
βββ scene engine/runtime scene stack with per-scene resource ownership, prefabs, transitions
βββ ECS engine/ecs generational entities, sparse-set component pools
βββ Lua engine/script Lua 5.4 VM: own allocator + budget, engine-scheduled GC, sandbox
βββ memory / GC engine/memory Frame/Pool/Object/Asset/Buffer allocators, pool-backed STL allocator
βββ assets engine/assets registry, ref-counted handles, cache, loaders
βββ serialization engine/serialization tagged, versioned binary sections
βββ save states engine/runtime save_state / load_state / snapshot / restore / state_hash
βββ networking-ready state engine/net rollback buffer: snapshots + input log, rewind & resimulate
βββ WASM runtime engine/wasm binary decoder, interpreter, shared linear memory, buffers
βββ kernel compiler engine/kernel CUDA-like source β AST β IR β constant folding β WASM
βββ Unreal backend backends/unreal UE5 plugin implementing unify::Backend (not compiled here, see Status)
```
## Build and run
Requires CMake β₯ 3.16, a C++17 compiler, and optionally SDL2 (`libsdl2-dev`) for the windowed backend.
```bash
cmake -S . -B build -G Ninja
cmake --build build # also generates the game's sprites, SFX and music (unify_assetgen)
ctest --test-dir build # 58 unit/integration tests + the definition-of-done playtest
./build/unify_game # SDL2 window: arrows/AD move, Space jump, F5 save, F9 load, Esc menu
./build/unify_game --headless --frames 600 --screenshot out.png
./build/unify_playtest game/assets out/ # scripted playthrough with verification + screenshots
```
## The engine loop
Every real-time frame runs these stages. The playtest prints this order from the engine's own trace.
```
INPUT β INPUT BUFFER β FIXED SIMULATION β PHYSICS β GAMEPLAY / LUA β ANIMATION β AUDIO β RENDER PREPARATION β RENDER β PRESENT
ββββββββββββββββββββ zero or more fixed 60 Hz ticks βββββββββββββββββββ
```
There are five separate clocks:
| Clock | Where | Used for |
|---|---|---|
| Wall / input clock | `Backend::now_ns()` | timestamping OS events, deciding when ticks are due |
| Simulation | `SimClock` (integer ticks) | all gameplay, physics and animation. Seconds are derived from ticks, never accumulated |
| Render | `RenderClock` | interpolation factor between the last two sim states |
| Audio | `Mixer::frames_rendered()` | advanced by the audio device thread, independent of frame rate |
| Input | event timestamps | an event is consumed by the tick whose window contains it |
A tick is due when `origin + (tick+1)Β·1e9/rate β€ now`. After `max_ticks_per_frame`, a stall drops time
rather than spiralling. Rendering interpolates transforms, so 30, 60 and 144 FPS all run the same
60 ticks per second (tested).
**Input buffering.** OS events are queued with timestamps. Each tick drains the events stamped before
its end and folds them into an `InputFrame`: `pressed`, `released`, `repeated`, `held`, axes and pointer.
A press and release that both land between two ticks still reads as *pressed* in the next tick; the
playtest checks this with a 3 ms tap. `input:buffered("jump", n)` gives gameplay a press window.
## Determinism, save states, networking
- One seeded RNG (xoshiro128**) is the only random source. Lua's `math.random` is rebound to it.
Lua has no `os` or `io`, so no wall-clock reads.
- The build uses `-ffp-contract=off -fno-fast-math`, and every container that affects the simulation
iterates in a deterministic order.
- **Save state** = tagged binary sections: `HEAD` (magic, version, engine, tick rate), `TIME` (tick,
timers), `RNG`, `SCEN` (scene stack, owned assets by name, buffers, kernels), `ECS` (entity pool and
component pools in dense order), `PHYS` (bodies, *and contact impulses for warm starting*), `ANIM`,
`INPT`, `CAMR`, `COMP` (live WASM buffers and kernel sources), `LUA` (the `game` table plus each scene's
`state`), and `AUDI` (voices by asset name and position). There are no raw pointers; handles and names
only. Unknown sections are skippable, and newer versions are refused.
- `save_state()` / `load_state()` from Lua are applied at the tick boundary. `snapshot()` / `restore()`
are the in-memory forms, and `save_to_file()` writes atomically.
- **Round trip:** restore reproduces the saved `state_hash()` exactly, in the same engine or a fresh one.
Replaying the recorded input from a restored state reproduces the original continuation bit for bit.
- `engine/net/rollback.h` keeps periodic snapshots plus the input log. `correct(tick, frame)` rewinds and
re-simulates. A test proves that a late-corrected timeline equals the true one.
## Lua API
Scenes are Lua files returning a table with `init`, `load`, `update(dt)`, `render`, `on_contact(a, b,
kind, nx, ny, impulse)`, `on_timer(name)`, `on_saved`, `on_loaded` and `unload`. UNIFY owns the lifecycle.
```lua
entity:set_position(x, y) entity:set_velocity(x, y) entity:set_animation("run", blend)
physics:apply_force(entity, x, y) physics:raycast(x1, y1, x2, y2, mask, ignore) physics:add_static_box(...)
audio:play("audio/jump") audio:stream("music/theme") audio:stop(id, fade) audio:set_bus_volume("music", v)
input:is_pressed("jump") input:is_held("left") input:buffered("jump", ticks) input:axis("move")
world:spawn("prefabs/player", x, y) world:find(name) camera:set(x, y, zoom) ui:text(...) -- ui only in render()
timer.after(sec, name) scene.transition("level") animation.define{...} map.load("maps/level1")
buffer.create(n) buffer.upload(b, t) buffer.download(b) kernel.compile(src) kernel.load(k) kernel.dispatch(k, grid, block, ...)
save_state([slot]) load_state([slot]) gc_step() gc_budget(kb) gc_pause() gc_resume() gc_collect()
```
Lua only ever holds handles: entities, buffers and kernels are userdata that wrap a generational handle,
checked on every call. A destroyed entity raises `stale entity handle` instead of touching freed memory.
Lua memory comes from its own allocator with a hard cap (the default is 64 MB). The collector is stopped,
and the engine runs one budgeted incremental step per tick, plus a full collection only on scene loads.
## Kernel compiler and WASM runtime
```c
__global__ void vadd(const float* A, const float* B, float* C, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) C[i] = A[i] + B[i];
}
```
**Source β AST.** A lexer and recursive-descent parser handle `int`/`float`, pointers, `if`/`for`/`while`,
`break`/`continue`, casts, `sqrtf`/`fabsf`/`fminf`/`fmaxf`/`min`/`max`, and `threadIdx`/`blockIdx`/`blockDim`/`gridDim`.
**AST β IR.** A typed, name-resolved, structured IR with explicit conversions, printed by `print_ir`.
Constant folding and dead-branch elimination run on it.
**IR β WASM.** Each module imports `env.memory` and exports the kernel plus `<name>__dispatch(grid, block, ...)`,
which loops over the whole grid inside WASM.
**Runtime.** It decodes and validates the binary, builds block side tables, and interprets i32/i64/f32/f64.
Out-of-bounds access, divide by zero, stack overflow and fuel exhaustion are *traps*, returned as errors.
All kernels share one linear memory, and buffers are ranges handed out by `BufferAllocator`.
The demo runs `vadd` on the title screen and integrates the sparkle particles with a second kernel.
## Backends
| Backend | Render | Audio | Input | Status |
|---|---|---|---|---|
| Headless | software device, offscreen | null device thread (real time) | programmatic / replay | built and tested |
| SDL2 (native) | software device β SDL texture, integer scaling | SDL audio callback | keyboard, mouse, game controller, touch | built; smoke-tested with SDL's dummy drivers |
| Unreal | `FCanvasTriangleItem` per batch, transient `UTexture2D` | `USynthComponent` | `AnyKey` bindings | **source only, not compiled** (no UE toolchain in CI) |
The renderer is a `RenderDevice` interface. The engine ships a software rasterizer as its reference
device, and a GPU device would plug in at the same interface.
## Verification
- `unify_tests`: 58 tests covering every physics edge case in the spec (tunneling, bullets, resting
contacts, stacking, penetration, zero and infinite mass, tiny and huge bodies, coincident centres,
parallel edges, corner, edge and multi contacts, triggers, sleep and wake, filtering, NaN containment,
exact save/restore). They also cover input buffering, the mixer, streaming and loop points, the audio
device thread, renderer batching, blending, scissor and rotation, animation modes and blending, the
allocators, handles, RNG, the kernel pipeline, WASM traps, scene-transition leak checks, determinism
across engines, fresh-engine restore, Lua sandboxing, the handle and memory caps, GC scheduling,
**zero heap allocations per steady-state tick**, stage order, frame-rate independence, and rollback.
- `unify_playtest`: the definition of done, from BOOT to CONTINUE PLAYING, on the real game with
scripted input. Each stage is verified from engine state (hashes, counts, clips, voices) and
screenshots are written.
- The whole suite and the playtest are clean under AddressSanitizer and UndefinedBehaviorSanitizer.
## Not included / known limits
- The **WASM *platform* backend** (compiling UNIFY itself to WebAssembly for browsers) is not built. It
needs Emscripten, which isn't available here. The engine core has no platform dependencies, so it is
a backend-sized job. UNIFY's WASM *runtime* for kernels is fully built.
- **Unreal backend**: see the table above.
- **Compilers**: builds with GCC and Clang, and with MinGW-w64 GCC on Windows (runtime linked statically).
MSVC is not supported: the code uses `__builtin_clz` and `__uint128_t`.
- Audio decodes PCM16 WAV only. Physics has no joints. Scissor rectangles are not applied on the Unreal
backend. The software renderer is the only render device.
|