Development history¶
This is the engine's original README, kept whole as a development log: how each system was built, what went wrong, and how it was verified, in the order it happened. Mentions of "this README" mean this file. For the current state of each system see ROADMAP.md, for defects BUGS.md, and for getting started the project README. Links to the other design notes point at their current home in
docs/.
Kreative Kompas Engine (KKE)¶
A bare-bones, modular Vulkan game engine built from "lego pieces" — each
library does one job, and every gameplay/rendering system beyond the core
frame loop is a Module that plugs into an Application, not a
special case baked into the engine.
This engine is AI-coded. Same disclosure as my earlier
3dco+project: the code here was written by Claude (Anthropic) working directly in this repo, iterating with me over a series of Vulkan/CMake build-and-run cycles, not hand-written line by line. Read it, question it, and treat it the way you'd treat any other codebase you didn't personally write every line of — that's true regardless of who or what wrote it, but it's especially worth saying plainly here.
Branding: every game opens with the Kreative Kompas intro: the logo,
extruded into a bevelled 3D emblem, assembles itself (the ring's arcs fly
in, the Ks swing into place, the crown drops, the needle spins and settles
on north), a light sweep crosses it and the name fades in. It lasts 4.5
seconds, any key or click skips it, and its last frame stays up while the
game loads (kke::LogoIntro, played by Application::run() before module
init). Games turn it off with app.setIntroEnabled(false); KKE_SKIP_INTRO=1
skips it for one run (benchmarks, automated tests) and KKE_INTRO_AT=3.2
freezes it at that second (screenshots). The logo is also every window's
icon (Linux/Windows via SDL3) and the Windows .exe icon. Sources and how
to regenerate the 3D outlines: assets/README.md → Branding. Not done yet:
a macOS .icns + bundle Info.plist.
Licence: MIT (see LICENSE). Vendored and fetched libraries keep
their own licences (e.g. external/FEMFX/, fonts in assets/fonts/).
The Kreative Kompas logo and banner are the project's branding; Synty
packs are never part of the repo.
Support and plans: KKE is built by one person. If you want to help it
exist, Ko-fi is the place. How the engine
will reach people (free-only launch plan, funding ladder) is in
GO_TO_MARKET.md; open work is tracked as GitHub issues by milestone.
For AI agents working on this codebase (this one, a fork, a future
model): read AI_GUIDE.md first. It's a short, model-agnostic orientation
doc — this README is where the actual depth lives, but the guide tells
you the non-negotiable rules (verify before claiming done, small slices,
update the Roadmap below, no closed-source dependencies) before you start.
This is built iteratively, in small verified slices, across many
sessions — not in one pass. Every increment below was built, actually
compiled, actually run (headlessly against lavapipe, screenshotted or
log-inspected), before being called done. The Roadmap section near the
bottom is the living, honest list of what that process hasn't reached
yet — treat it as this project's memory, not aspirational marketing.
What's actually in this milestone¶
- A window (SDL3) with a Vulkan swapchain, depth buffer, and render pass
- A module system (
kke::Module+kke::Application) that drives everything else — see "Architecture" below - A spinning cube (
CubeModule) — the original milestone, ported into the module shape. A real, reported rendering bug was found and fixed here: with the default backface culling, one triangle of the cube's top face would intermittently vanish at certain rotation angles — confirmed via real screenshots (background grid lines visible right through the gap), not just described. Every face's vertex winding checks out correctly by hand (cross-product against each face's own outward normal, all six faces) and the depth-test configuration is standard, so the actual runtime culling decision depends on something in the view/projection handedness that static analysis didn't capture — not fully explained, but empirically fixed and verified: six screenshots across a full rotation with culling disabled show a completely solid cube every time, matching the same six moments that showed the gap with culling on. Same pattern this codebase already uses forPhysicsModule's own rendered geometry (see "Physics: AMD FEMFX integration") — culling is simply off for this small demo geometry rather than reverse-engineered further. - A depth-correct reference grid (
GridModule) so you have a fixed sense of scale and perspective in the scene - Mouse-driven camera control (
OrbitCameraModule) — left-drag to orbit, right-drag to pan, scroll to zoom, plus an auto-orbit checkbox - A GPU compute-driven particle system (
ParticleModule) — 20,000 particles simulated entirely on the GPU, no CPU-side particle array - A performance overlay (
StatsModule) — FPS, CPU frame time, and GPU frame time (via Vulkan timestamp queries), each with a rolling graph - A cross-module communication system: typed dependencies (
getModule<T>()), capability-based discovery (findCapability<T>()), and a fixed-timestep tick for deterministic simulation — demonstrated end-to-end byDestructionModule(seed-based, tick-deterministic) andNetworkModule(discovers it via capability, with zero concrete-type coupling) - A real test suite (
tests/, GoogleTest, 33 tests passing) with measured coverage — 99.1% on pure-logic code, 94.0% across the whole engine including Vulkan — and a CI pipeline (.github/workflows/ci.yml): build, 100+ unit tests and headless smoke runs of every demo gate each push tomain; coverage is measured and reported (85% floor paused during the POC phase). See "Test suite & coverage." - Async, non-blocking, formatted/colored logging via spdlog (a
representative subset of call sites migrated so far — see "Logging"),
and a developer-declared hardware-requirements check (
game.json'srequirementssection vs. real queried hardware, warn-only, verified both passing and failing on real output — see "Estimated hardware requirements"). - Debug pause/step (freeze simulation, single-step one tick at a time)
and per-module fault isolation (a module that throws is logged,
disabled for the rest of the session, and never crashes anything
else) — both verified with real repros, not just code review. Plus
kke::EngineError— structured errors with a plain-language message, a source (script/engine/unknown), and a file/line, shown as the headline in the Emergency Log instead of a raw C++ exception string — see "Debugging: pause/step and per-module fault isolation." - AMD FEMFX (deformable-material FEM physics), patched to build on
Linux/GCC from ~1,871 initial compile errors down to a real, linked,
running library — verified twice, standalone and inside this
engine's own CMake build.
kke::PhysicsModulenow exposes a real, general, runtime-callable spawn API (spawnTetMesh()/spawnTetrahedron()/removeObject()) that accepts genuinely arbitrary tetrahedral meshes, not just one hardcoded shape. Meshes become tetrahedra at runtime viakke::voxelizeToTets(the earlier offline CGAL tool was removed — see "Content pipeline");.ktet.jsonfiles load withkke::loadTetMeshFromFile(). Verified genuinely end-to-end in a live session: a real 401-tetrahedron mesh, produced by the (since removed) offline tool, spawned via this API, and observed falling under real gravity to a stable rest. Got there viagdb-traced debugging through four distinct real bugs in the physics integration alone (three fixed, one open but non-blocking; see "Physics: AMD FEMFX integration" for the full account, including one bug that explained two separate- looking symptoms at once). The task system is now genuine multithreading too, not the earlier synchronous stand-in — a real thread pool, verified standalone before integration, catching a real correctness risk (per-worker scratch-buffer indexing, confirmed directly in FEMFX's own source) and a real deadlock (numWorkers==1 queuing into an empty pool) before either could bite.tools/ physics_benchmarkexists so the actual speedup can be measured honestly on real multi-core hardware — this sandbox's single core can only prove correctness, not performance, and says so plainly rather than reporting a misleading number. Opt-in viaKKE_ENABLE_FEMFX(default OFF); the tetrahedralizer tool is separately opt-in viaKKE_ENABLE_TETRAHEDRALIZER(also default OFF). - VulkanProfiler (
VK_LAYER_PROFILER_unified) integration — conditionally enabled viaKKE_ENABLE_GPU_PROFILER, actually built from source, installed, and confirmed running against this engine with a real screenshot of its live overlay reading this engine's actual per-frame GPU/CPU timing. Pulling that data into our ownStatsModule/logs viavkGetProfilerFrameDataEXTis written but disabled by default after a real,gdb-diagnosed crash inside the layer's own code (not this engine's) — see "GPU profiler (VulkanProfiler) integration" for the full incident writeup. - The engine/game directory split is real:
games/kke_demo_game/is a self-contained "game folder" with its owngame.jsonmanifest,kke::MarketplaceIndex(verified bytests/marketplace_test.cpp, which passes) scans and imports such folders idempotently, andMarketplaceUiModulerenders the result as a real, on-screen RmlUi card. Non-interactive, scans once at startup, no sandboxing — see "Game folder convention & marketplace" for exactly what this does and does not mean yet. - Dear ImGui wired in for all of the above
- RmlUi with a real Vulkan render backend, including textures and text
(
UiModule+RmlVulkanRenderInterface) — RmlUi's actual layout engine computing real positions/sizes and shaping real text via FreeType, compiled into this engine's ownBuffer/Pipelineprimitives (plus a VMA-backed image/sampler/descriptor path for glyph atlases), drawn via genuine Vulkan draw calls. Verified by an on-screen test document with actual anti-aliased, word-wrapped text in a bundled default font over alpha-blended colored panels. Image-file textures (<img>,background-image) are still stubbed — that needs stb_image, tracked separately in the Roadmap.
Verified by an actual build-and-run pass, including headlessly against
Mesa's lavapipe software Vulkan device under Xvfb — screenshots, not
just "it compiles."
Stack¶
| Piece | Library | Job |
|---|---|---|
| Windowing/input | SDL3 | window, events, Vulkan surface creation |
| Vulkan loader | volk | loads Vulkan function pointers, no libvulkan linkage needed |
| GPU memory | VMA | buffer/image allocation |
| Math | GLM | vectors, matrices, transforms |
| Debug UI | Dear ImGui v1.91.6 | performance overlay, per-module debug panels |
| Game UI | RmlUi 6.3 + FreeType | HTML/CSS-inspired UI; real Vulkan rendering incl. text/glyph textures — image-file textures (<img>) still stubbed |
| Default fonts | Noto Sans + Noto Color Emoji (OFL-1.1), bundled at assets/fonts/ |
fallback every game gets even without loading its own, including verified-working color emoji; see "Default fonts" section |
| Data/manifests | nlohmann/json 3.11.3 | game.json manifests, the marketplace index — see "Game folder convention & marketplace" |
| Testing | GoogleTest 1.15.2 | unit tests for pure-logic code — see "Test suite & coverage" |
| Logging | spdlog 1.14.1 | async, non-blocking, colored, formatted logging — see "Logging" |
| Physics | AMD FEMFX (patched fork, vendored at external/FEMFX/) |
deformable-material FEM physics — cloth/cushions, impact deformation, density-based destruction, melting. Opt-in via KKE_ENABLE_FEMFX. See "Physics: AMD FEMFX integration" |
| Images | stb | fetched, not consumed yet — no texture loading until there's a texture |
| Scripting | Lua 5.4.7 (MIT), compiled as C++ | gameplay scripts, Garry's Mod-style hooks/timers, hot reload, sandboxed — see SCRIPTING.md |
| Particles | custom (GPU compute, see below) | see "Why a custom particle system" |
| Audio output | miniaudio 0.11.22 (public domain / MIT-0) | output device on every OS, WAV/FLAC/MP3 decoding; mixing, 3D, occlusion and impact synthesis are our own — see AUDIO.md |
All dependencies are fetched from source via CMake FetchContent — same
version on every platform, no system package hunting.
Lighting — a real multi-light system, verified visually¶
A genuine multi-light system exists now, not a single hardcoded
light: kke::Light/kke::Lighting on Application (mirroring the
existing Camera pattern — most modules want to read this, so it
lives on Application directly rather than behind a getModule<>()
lookup) hold up to 4 directional or point lights plus an ambient
color, configurable from any game's own code. A new LightingBuffer
class owns the actual GPU uniform buffer and descriptor set feeding
this into every lit shader, updated once per frame and shared by every
module that draws lit geometry — RenderContext now carries the
descriptor set through to render(), the same way view/proj/
cameraPos already did. cube.frag loops over all 4 lights doing
real Blinn-Phong shading (diffuse and specular highlights, not
just flat diffuse) for whichever are enabled.
Verified as genuinely multiple lights, not just one moved into a
UBO: games/physics_demo now runs a warm key light plus a second,
cooler-toned fill light from roughly the opposite side — confirmed
with real screenshots that the floor is visibly, measurably brighter
with both lights active than with the single default light alone, the
one comparison that actually distinguishes "two lights blending" from
"a second light silently being ignored."
A real, substantial crash found and fixed getting here — worth the
honest account: after building all of this and confirming it
compiled clean, kke_demo started, logged its hardware check
successfully, and then died with no error message at all — just
silently gone. gdb -batch -ex run -ex bt (not guessing) traced it to
vkCreateGraphicsPipelines segfaulting deep inside the Vulkan driver,
called from DestructionModule::init() — a third consumer of the
shared cube.vert/cube.frag shaders, alongside CubeModule and
PhysicsModule, that got completely missed when the shaders started
requiring a light descriptor set and a larger push-constant struct.
Its pipeline was being created with neither, an invalid mismatch
between what the shader now declares and what the pipeline layout
actually provides — validation layers aren't available in this
project's sandboxed test environment to catch that cleanly, so it
crashed instead of erroring. Found every consumer this time by
actually searching for cube.vert.spv/cube.frag.spv across the
whole codebase (three files, not two) rather than fixing one crash and
assuming that was the only one — fixed all three consistently, then
re-verified: kke_demo's cube, its destruction-fragment explosion
(triggered live, screenshotted mid-flight), and physics_demo's
ground and tetrahedra all render correctly with real, visible shading.
Two real, honest simplifications in this system, not oversights:
- Normals are transformed by mat3(model) (rotation + scale, ignoring
translation), not the mathematically general inverse-transpose. This
is exactly correct for rotation and uniform scale — everything this
engine currently draws — and only becomes wrong under non-uniform
scale. Checked directly for the one real non-uniform-scale case that
exists (the physics ground's thin slab, scaled differently on Y than
X/Z): its normals are all axis-aligned, and an axis-aligned normal
under a diagonal (even non-uniform) scale matrix stays exactly
correct after normalizing — so this simplification happens to be
exact here too, not just "close enough." Worth revisiting properly
if a future mesh needs actual non-uniform scale with a
non-axis-aligned normal.
- Physics objects didn't have real per-vertex normals before this
slice at all — adding a normal field to the shared Vertex struct
without also computing real values for PhysicsModule's tetrahedra
would have left them lit by uninitialized memory. Fixed by summing
each vertex's adjacent face normals (weighted by face area, via the
un-normalized cross product) and normalizing once — correct,
meaningful shading for real exterior-facing geometry, not full
smooth-shading correctness across a mesh's interior (which doesn't
matter, since interior faces are never visible).
- Camera position (needed for the specular half-vector) is threaded
through the lighting UBO rather than push constants — push constants
are already at 128 bytes with mvp+model, the commonly-guaranteed
minimum on some hardware. Not perfectly semantically "lighting" data,
a pragmatic, documented choice given that real constraint.
See "What's still ahead" further down for the real remaining plan — shadows, PBR, a way to add point lights dynamically from gameplay code at runtime (the current API sets fixed lights at startup) — none of which this system attempts yet.
What's still ahead for lighting¶
Shadow mapping (a single directional light, single shadow-casting
module), PBR materials (Cook-Torrance, real metallic/roughness), real
flat shading for physics-spawned tetrahedra (fixed a genuine "hollow"-
looking smooth-shading bug — see "Immediate next slices" above), and
real material albedo textures with real per-face UV mapping (a
checkerboard on CubeModule, and a five-texture library in
PhysicsModule tied to MaterialGridModule's presets — real UV-mapped
sampling now, not flat tints, though fine pattern detail on a small
tetrahedron is limited by ordinary texture minification at that scale)
are all real and working now — see "Immediate next slices" above for
the full account of each, not summarized twice here. Still entirely
unbuilt, checked directly rather than assumed: real image-based
ambient lighting (a captured/convolved environment map — the current
ambient term is still a flat color * albedo stand-in), normal maps
and roughness/metallic textures (still one number per object, not a
per-pixel sample), texture loading from real files for objects other
than CubeModule (every texture so far is procedurally generated, not
loaded), point-light shadows, cascaded/multiple shadow maps for larger
scenes, soft shadows (PCF or better), shadow casting generalized
beyond the one module that currently implements it, and a way for
gameplay code to add/remove lights dynamically at runtime rather than
configuring the fixed 4-slot array at startup. Building all of that
out is still genuinely substantial work — Vulkan gives no plug-and-play
lighting the way some higher-level engines do; every piece has to be
written.
The real plan, and the exact resources to build it from — recorded here specifically so both a human and an AI picking this project back up have the same starting point, not scattered notes:
- Sascha Willems' Vulkan Samples — the reference implementation for nearly everything this engine will eventually need: deferred shading (many dynamic lights efficiently), shadow mapping (directional/omnidirectional/cascaded), and a full PBR pipeline. Working, runnable Vulkan code, not just theory — the first place to look for "how does a real Vulkan engine actually implement X."
- LearnOpenGL — the math and theory (Blinn-Phong, attenuation, PBR) transfers almost directly to Vulkan even though the code examples are OpenGL/GLSL. The best place to actually understand the lighting equations before implementing them, rather than just copying a sample.
- vkguide.dev — a from-scratch modern Vulkan renderer walkthrough, particularly strong on compute shaders and efficient buffer management — relevant for light culling once there's more than one or two lights on screen.
The architectural direction: deferred rendering (or clustered forward rendering once there are enough lights to matter) — render geometry data (positions, normals, colors/material properties) into a G-buffer first, then compute all lighting in a second pass that reads those textures. This keeps performance reasonable with many lights, rather than recomputing full lighting per-object per-light in a single forward pass.
What real PBR content will need that isn't here yet: fastgltf — a modern glTF 2.0 loader that captures PBR material data (roughness/metallic maps) directly. Not yet added as a dependency; needed once there's an actual lighting pipeline for it to feed data into, not before.
Build¶
See BUILDING.md for the real, complete setup guide — exact
system packages for Debian/Ubuntu and Arch, the single-command build,
how to run each demo, and a troubleshooting section. It exists because
this section alone wasn't enough: a real person building on real
hardware (AMD Radeon RX 9070XT, Arch Linux) hit missing packages, a
Boost detection quirk, and a real Vulkan crash that this project's
original sandboxed development environment never surfaced. All of
that is fixed now (see "Real hardware findings, fixed" below) and
documented properly in BUILDING.md, not just patched quietly.
Cross-machine build/test benchmarking: cmake -P tools/
build_benchmark.cmake everything — one genuinely OS-agnostic command
(a CMake script, so it needs nothing beyond CMake itself on Windows/
Linux/macOS alike) that wipes any existing build/ directory first
(a stale one would make timing comparisons meaningless), then
configures, builds, and runs the full test suite, timing each step and
counting real warnings/errors from the captured output. Writes one
timestamped, hostname-tagged log file to benchmark_logs/ — see
BUILDING.md "Cross-machine build benchmarking" for the full
picture, including why this is a CMake script rather than bash/
PowerShell. Verified against both a genuine success and a genuine
failure (a real, reproducible configure failure — this sandbox can't
reach lua.org — correctly stopped early with the real error captured
in the log, rather than plowing ahead or failing silently).
The short version, once system packages are installed:
cmake --workflow --preset everything # configures AND builds, every optional feature on
cd build/bin && ./kke_demo
kke_demo is the walkable showcase (games/showcase/, needs
KKE_ENABLE_JOLT, on by default): click to control, WASD/Shift/Alt/Space/C
to move, mouse to look, V first/third person, F or left click shoots the
breaking yard, E or right click pushes crates, R resets, F1 shows the engine
panels. The character is Quaternius' Universal Animation Library mannequin
(CC0): put UAL1_Standard.fbx in assets/animations/ (git-ignored, 23 MB),
or the character is a box. UAL2.fbx (volume 2, 70 MB) next to it adds the
real vault and climb clips. The old building-block demo is kke_basics.
Multiplayer (see NETWORKING.md): host or join a
kke_demo game on your LAN or on one PC, from the F1 Network panel
(Host, Join, Search LAN) or the command line:
KKE_NET=host ./kke_demo in one terminal, KKE_NET=join:127.0.0.1 ./kke_demo
in another. You see each other walk, vault and climb, push the same crates
and see each other's shots. KKE_NET_LAG, KKE_NET_JITTER and
KKE_NET_LOSS (or the panel sliders) simulate a bad connection.
Input (see INPUT.md): every control is an action you can
rebind to anything: keys, mouse, gamepads (incl. paddles and gyro), HOTAS
and other raw joysticks, identical twins (HOSAS) kept apart by USB port,
with Tarkov-style press/hold/tap/double-tap/toggle triggers and chords.
The RmlUi demo's Input tab tests every peripheral live and edits the
bindings (saved to input.json, shared by the demos); kke_demo has a
left-handed key mirror. On Linux install libudev-dev (controller
hot-plug) and libdbus-1-dev before configuring, or SDL builds without
them.
cmake --workflow --preset default matches this project's actual
default option values (no FEMFX, no tetrahedralizer, no GPU profiler;
Lua is on) if you want the smaller, faster build instead. Both need
CMake 3.25+ for workflow presets specifically — BUILDING.md
covers the manual, flag-by-flag equivalent for older CMake.
Run demos from build/bin/, not the repository root — shaders,
fonts, and each demo's game.json are copied next to the compiled
executable at build time, and relative paths assume that location.
Other platforms: Docker builds¶
Like your other projects, each platform builds in its own container
(docker-compose.yml, docker/); the source is mounted, output goes to
dist/<platform>/:
docker compose run --rm linux # Ubuntu 24.04, builds everything, runs the unit tests
docker compose run --rm windows # MinGW-w64 cross build -> .exe, unit tests under Wine
docker compose run --rm android # NDK arm64-v8a native build (FEMFX off: see SCALING.md)
macOS/iOS can't be built in a container (Apple's SDK licence): the manual "Platforms" GitHub workflow builds macOS (and Windows with MSVC) on GitHub's runners. What works on which platform, what blocks the rest (FEMFX is x86-AVX only; browsers have no Vulkan) and the plan: SCALING.md, section D.
Real hardware findings, fixed¶
Everything in this subsection was found by an actual person building on actual hardware, not anticipated in advance:
- A real Vulkan crash (
vkCreateInstancefailing withVK_ERROR_LAYER_NOT_PRESENT, real AMD hardware,KKE_ENABLE_GPU_ PROFILER=ON) — a required extension (VK_EXT_layer_settings) wasn't enabled alongside the profiler layer's settings chain, which this project's original software-rendered (lavapipe) test environment tolerated but a real driver's stricter validation did not. Fixed, plus a genuine robustness addition on top: if enabling the profiler layer still fails atvkCreateInstance(a real, distinct failure mode from "layer not installed" — a layer can be enumerable without being loadable), the engine now retries once without it instead of crashing. - A real Boost/CMake/CGAL detection quirk on Arch Linux — (historical: the CGAL tool it affected was removed on 2026-09-26.)
- Real, unnecessary build warnings — two were genuine bugs, fixed
properly rather than suppressed: this project's own earlier
portability patch in
FEMFXTypes.hwas unconditionally redefiningFM_FORCE_INLINEafterFEMFXVectorMath.hhad already set it (fixed with#undef), and two vendoredqsort_*.cppfiles redefined glibc's own__Pmacro without checking if it already existed (fixed with a guard). The remainder — SDL3's own vendored source, a couple of FEMFX-internal patterns not worth rewriting deep in AMD's own threading code — are suppressed narrowly at the target level, not blanket-silenced. Verified with a full clean rebuild of both thedefaultandeverythingpresets: genuinely zero warnings, down from 30+. (2026-09-26: that was with the compiler's default warnings only. Our own code now builds with-Wall -Wextra, every warning that turned up was fixed rather than silenced, and CI's plain build uses-DKKE_WARNINGS_AS_ERRORS=ONso a new one fails the build. See BUGS.md BUG-058.) - No single-command build —
CMakePresets.jsonadded (see above).
Headless smoke test (what we used to verify this milestone)¶
Works against Mesa's lavapipe software Vulkan driver, no GPU required
— useful for CI and for the kind of sandboxed verification this project
has been built with throughout. Real hardware testing matters too,
though — see "Real hardware findings, fixed" just above for what this
alone didn't catch.
Architecture: the module system¶
Everything that isn't core frame plumbing (window, swapchain, sync
objects, command buffers) is a kke::Module:
class Module {
public:
virtual const char* name() const = 0;
virtual void init(Application& app) {} // create GPU resources
virtual void update(const UpdateContext& ctx) {} // CPU simulation
virtual void compute(VkCommandBuffer cmd) {} // compute dispatches, pre-render-pass
virtual void render(const RenderContext& ctx) {} // draw calls, render pass active
virtual void renderUi() {} // ImGui panels
virtual void shutdown() {} // destroy GPU resources
};
kke::Application owns the window, the Renderer, the debug UI, and a
list of Modules, and drives them all through one loop:
poll events
for each module: update(dt)
build ImGui panels (renderUi)
if renderer.beginFrame(): // acquire swapchain image, start recording
for each module: compute(cmd) // compute dispatches happen here, before...
renderer.beginRenderPass() // ...the render pass starts
for each module: render(ctx) // draw calls, render pass is active
debug UI draws on top
renderer.endFrame() // end render pass, submit, present
A module that doesn't render anything — a physics step, a networking
client polling sockets — just never overrides render()/compute().
Partial implementations are the normal case, not a workaround.
main.cpp is deliberately almost empty:
kke::Application app("Kreative Kompas Engine - Demo", 1280, 720);
app.addModule<kke_demo::CubeModule>();
app.addModule<kke::GridModule>();
app.addModule<kke::ParticleModule>(20000);
app.addModule<kke::StatsModule>();
app.run();
That's the "simple game loop / demo" in its entirety — a real game's
main.cpp should look almost exactly like this, with your own modules
in place of CubeModule.
Where the rendering code actually lives¶
| If you want to see... | Look at |
|---|---|
| The frame lifecycle itself (acquire/record/submit/present, GPU timing) | engine/src/Renderer.cpp |
| How a render pass, framebuffers, and the depth buffer are set up | engine/src/SwapChain.cpp |
| How a graphics pipeline is configured (cull mode, blend, depth, push constants) | engine/include/kke/Pipeline.h's PipelineConfig struct |
The simplest possible draw — no vertex buffer, no mesh, just a pipeline and a vkCmdDraw |
engine/src/modules/GridModule.cpp |
| A conventional draw — vertex/index buffer, push-constant MVP matrix | games/kke_demo_game/CubeModule.cpp |
| A compute shader feeding a graphics pipeline through a shared buffer | engine/src/modules/ParticleModule.cpp |
| GPU-side profiling (Vulkan timestamp queries) | Renderer::createQueryPools/beginFrame in engine/src/Renderer.cpp |
The actual "how do I draw something" recipe, concretely:
- In your module's
init(Application& app), build akke::Pipelinewith aPipelineConfigdescribing your fixed-function state, pointing at compiled.spvshaders. - In
render(const RenderContext& ctx), callpipeline.bind(ctx.cmd), push whatever constants your shader needs (ctx.view/ctx.proj/ctx.cameraPosare already computed for you), bind any vertex/index buffers or descriptor sets, and issue yourvkCmdDraw*call. Applicationcalls this for every module, every frame, inside the one shared render pass — you never open or close a render pass yourself.
Adding a module¶
This is the concrete "how do I extend the engine" answer for things like a 3D destruction sim or a networking client.
- Decide what it touches. Pure simulation (networking, destruction
physics) only needs
update(). Anything that draws needsinit()(to build pipelines/buffers) andrender(). Anything GPU-compute-driven (destruction fragments simulated on the GPU, like the particle system) needscompute()too. - Write the class:
class DestructionModule : public kke::Module {
public:
const char* name() const override { return "Destruction"; }
void init(kke::Application& app) override {
// build fragment mesh/buffers, a Pipeline for drawing them
}
void update(const kke::UpdateContext& ctx) override {
// CPU-side: which objects fractured this frame, impulse data, etc.
}
void compute(VkCommandBuffer cmd) override {
// GPU-side fragment simulation, same shape as ParticleModule
}
void render(const kke::RenderContext& ctx) override {
// draw the fragments
}
};
class DestructionModule : public kke::Module, public kke::INetworkReplicable {
public:
const char* name() const override { return "Destruction"; }
void init(kke::Application& app) override {
// build fragment mesh/buffers, a Pipeline for drawing them
}
void fixedUpdate(const kke::FixedUpdateContext& ctx) override {
// deterministic — tick-indexed, not frame-rate-dependent
}
void render(const kke::RenderContext& ctx) override {
// draw the fragments
}
// kke::INetworkReplicable — see below
std::string replicationChannelName() const override { return "destruction.demo"; }
std::vector<uint8_t> serializeReplicatedState() override { /* seed + trigger tick, not geometry */ }
void deserializeReplicatedState(const std::vector<uint8_t>& data) override { /* ... */ }
};
class NetworkModule : public kke::Module {
public:
const char* name() const override { return "Network"; }
void init(kke::Application& app) override {
// discovers replicable modules without knowing their concrete types
for (auto* r : app.findCapability<kke::INetworkReplicable>()) { /* ... */ }
}
void update(const kke::UpdateContext& ctx) override {
// poll socket, apply incoming state, send outgoing state
}
// no render()/compute() overrides needed at all
};
- Register it in
main.cpp:app.addModule<DestructionModule>();
This is a real, working example, not a sketch — games/kke_demo_game/DestructionModule.* and
games/kke_demo_game/NetworkModule.* in this repo are exactly the two classes above, and
main.cpp adds both. Comment out the NetworkModule line and
DestructionModule behaves identically — it never references
NetworkModule or even knows the concept "network" exists.
Copy games/kke_demo_game/CubeModule.h/.cpp as the template for a
straightforward render-only module,
games/kke_demo_game/DestructionModule.h/.cpp for a
capability-implementing one, or engine/src/modules/ParticleModule.cpp
for a compute-driven one.
Game folder convention & marketplace¶
The engine/game split is a real directory boundary, not just a coding
convention: engine/ is the "lego pieces" (this repo, forked as-is),
and everything that makes a specific game a specific game lives in its
own folder under games/. games/kke_demo_game/ is that pattern's own
first example — it's not special-cased by the build; it's just the one
game folder CMakeLists.txt currently builds (see the Roadmap for
building/discovering more than one).
A game folder is: a directory containing a game.json manifest plus
whatever source/assets that game needs. The manifest is what makes the
folder recognizable and importable — everything else about the folder's
internal layout is up to the game.
game.json schema (see kke::GameManifest /
engine/include/kke/GameManifest.h for the authoritative parser):
| Field | Required | Meaning |
|---|---|---|
id |
yes | Unique dedup key, e.g. "com.yourname.gamename". This is what a marketplace uses to recognize "this is the same game," not the folder name or path — see below. |
title |
yes | Human-readable name. |
description |
no | Free text. |
version |
no | Defaults to "0.1.0". |
icon |
no | Path relative to the game folder. |
banner |
no | Path relative to the game folder. |
tags |
no | Array of strings, for marketplace filtering/search once that exists. |
engine_version |
no | Informational only right now — see Roadmap on actually checking compatibility. |
modules |
no | Array of module names this game uses/declares — informational/documentation today, not yet used to auto-resolve dependencies against what the engine build actually provides. |
requirements |
no | Developer-declared hardware requirements — see "Estimated hardware requirements" below. Not automatically inferred from the game's code. |
Estimated hardware requirements¶
You raised this as "runs on anything, as long as it can render at all —
think 'runs on a toaster'" with a genuine question of whether estimating
requirements from a game is even calculable. Being direct about the
honest answer before describing what's built: fully automatic
inference of hardware requirements from arbitrary game code isn't
realistic. There's no static analysis of a Module's render() that
tells you real-world VRAM or performance needs without actually running
and profiling it — draw call counts, texture memory, and shader
complexity all depend on runtime data (how many particles are alive
right now, what resolution the window is, what's actually on screen),
not just what code exists. Claiming otherwise would be presenting a
guess as a measurement.
The realistic version, built and verified: game.json gets an
optional requirements object, declared by the developer — the same
way a Steam store page's "minimum/recommended specs" are written by the
publisher, not computed from the executable:
"requirements": {
"minimum": { "vram_mb": 128, "vulkan_api_version": "1.0" },
"recommended": {
"vram_mb": 512,
"vulkan_api_version": "1.2",
"required_device_features": ["largePoints"]
},
"notes": "Free text explaining the numbers, e.g. what actually drives them."
}
At startup, kke::checkHardwareRequirements()
(engine/include/kke/HardwareCheck.h) compares this against the
actual running hardware — real Vulkan API version, real available
VRAM (via VMA's heap budget query), a small set of queryable device
features — and logs warnings for anything unmet. Verified both ways,
not just the happy path: temporarily set the demo's own recommended
VRAM to an absurd 999999 MB and confirmed the exact expected warning
fired —
[HardwareCheck][KKE Engine Demo][warning]: Recommended requirements ask for
~999999 MB VRAM; this device reports approximately 3198 MB available.
— then reverted it and confirmed the passing case logs cleanly instead:
[HardwareCheck][KKE Engine Demo][info]: Hardware check: this device meets
'KKE Engine Demo' recommended requirements.
(3198 MB is lavapipe's real reported budget in this environment — a software Vulkan device sharing host RAM, not a discrete GPU's dedicated VRAM, which is an honest thing to know about the number rather than treat it as universally precise.)
This only ever warns — it never blocks. Exactly per your framing:
if someone wants to run a game below its stated minimum and get 2 FPS
or a crash, that's their call, not the engine's to make for them. A
game that wants to actually refuse to launch below its minimum can do
that itself with HardwareCheckResult::meetsMinimum — that's the
game's decision, not something this function imposes.
Recognized required_device_features values: currently just
"largePoints" (used by ParticleModule's point-sprite sizing). An
unrecognized feature name is treated as unmet (with a warning saying
so), not silently ignored — a manifest declaring something the engine
can't verify shouldn't silently pass. Extend the recognized set in
HardwareCheck.cpp alongside whatever new capability VulkanDevice
learns to query.
Importing into a marketplace is exactly "copy the game folder into
the marketplace's directory." kke::MarketplaceIndex
(engine/include/kke/MarketplaceIndex.h) is the scanner: point
scanDirectory() at a folder full of game folders, and it imports every
one with a valid game.json, keyed by that manifest's id — not by
folder name. This is specifically what makes "importing a game twice
doesn't get stuck" true: copy the same game folder in again, under any
name, and MarketplaceIndex recognizes the id and updates the
existing entry in place instead of duplicating it. A folder without a
game.json is silently skipped (not every subfolder needs to be a
game); a folder with a broken one is skipped with a logged warning
rather than aborting the whole scan. MarketplaceIndex::save()/load()
persist the list and its order to a marketplace.json file — "the
marketplace's own ordering" the request asked for, independent of
whatever order the filesystem happens to return subdirectories in.
All of this is verified by an actual test
(tests/marketplace_test.cpp, run via ./build/bin/kke_marketplace_test)
that creates real folders on disk — including a deliberate same-id
duplicate under a different folder name, a broken manifest, and a
non-game folder — and asserts on the real scan result. It passes.
What this slice does NOT do yet — and this matters more than anything else in this section:
- ~~There is no marketplace UI~~ — done, minimally.
MarketplaceUiModule(engine/include/kke/modules/MarketplaceUiModule.h) depends onUiModule, scans a marketplace directory withMarketplaceIndex, and renders each game as a real RmlUi card — title, id, description, tags — verified on screen with the demo's owngame.json. Every manifest field is passed throughkke::escapeRmlText(engine/include/kke/RmlTextSafety.h) before being placed in the generated markup, since a game folder's manifest is exactly the "not this engine's own code" content that function exists for. This is non-interactive (no click-to-launch — RmlUi has no input wiring yet) and scans once at startup (no live refresh). Building this also caught a real bug worth naming: the first version escaped quotes as the named XML entities"/', and RmlUi's parser rendered'as five literal characters instead of decoding it — fixed by switching to numeric character references ("/'), which are more universally supported. Exactly the kind of thing a real test would catch mechanically instead of needing a screenshot — see "Test suite" below. - There is no compatibility checking.
engine_versionandmodulesare recorded but never validated against what the running engine build actually provides — a game folder declaring modules the engine doesn't have will currently just fail atadd_subdirectory/link time with a normal CMake/linker error, not a friendly marketplace message. - There is no dynamic multi-game build. The top-level
CMakeLists.txthardcodes oneadd_subdirectory(games/kke_demo_game). Building a second game folder today means adding a second explicitadd_subdirectoryline by hand —MarketplaceIndexscanning a directory and CMake actually building whatever it finds are two different pieces of work, and only the first one exists. - This is the big one: there is no sandbox, at all. A "game folder" in this engine is native C++ that gets compiled directly into the engine binary. It has full, unrestricted access to the machine it runs on — the filesystem, the network, everything — the same as any C++ you'd write yourself, because it is C++ you'd write yourself. This is a fundamentally different trust model from Roblox, where every game is sandboxed Luau script with no native code execution at all. A marketplace where anyone can upload a "game folder" and have it compiled and run on someone else's machine, in this engine's current state, is a way to distribute arbitrary native code with no isolation whatsoever. That is not a small caveat to fix later; it's the difference between "a marketplace" and "a way to run untrusted code." The Roadmap's Lua-scripting item is the actual path to something Roblox-comparable — a marketplace built on scripted game folders (sandboxed Lua, no native compilation of third-party code) is a plausible, buildable goal; a marketplace built on compiling arbitrary third-party C++ is not, without a much larger sandboxing/isolation effort (a separate process at minimum, likely a restricted execution environment, closer to how browsers isolate web pages than how this engine currently works). Stating this plainly now, before more is built on top of the current native-code assumption, rather than discovering it after a marketplace exists.
Cross-module communication¶
Two separate mechanisms, deliberately not merged into one, because they answer different questions:
Application::getModule<T>() — "give me the one module of concrete
type T, if it was added." For when a module genuinely can't function
without a specific other one (a gameplay module that needs the physics
module's results this frame). Pair it with declaring the dependency:
std::vector<kke::ModuleDependency> dependencies() const override {
return { { std::type_index(typeid(PhysicsModule)), /*required=*/true, "reads collision results" } };
}
Application::run() topologically sorts init() order from every
module's declared dependencies (Kahn's algorithm) and throws a clear
error naming the missing module if a required dependency isn't present
— you find out at startup, not three frames into a null-pointer crash.
fixedUpdate/update/render/etc. all run in that same dependency
order every frame, too.
Application::findCapability<Capability>() — "give me every module,
whatever its concrete type, that implements interface Capability." This
is the "recognize each other if both present, work fine standalone if
not" mechanism you asked for. Neither side declares a ModuleDependency
on the other, and neither side's header includes the other's — they only
share a small interface header (kke/Capabilities.h).
This repo's worked example is exactly your destruction/networking case:
kke::INetworkReplicable(engine/include/kke/Capabilities.h) is the shared interface:replicationChannelName(),serializeReplicatedState(),deserializeReplicatedState().DestructionModuleimplements it. It "shatters" into fragments whose transforms are a pure function of(seed, fragment index, elapsed ticks since trigger)— not integrated frame-by-frame, not stored per-fragment — so its entire replicated state is 17 bytes: an 8-byte seed, a 1-byte triggered flag, an 8-byte trigger tick. That's true whether it's idle or mid-explosion with fragments flying everywhere — verified by actually triggering it and watching the reported payload size stay at 17 bytes.NetworkModulenever includesDestructionModule.h. Ininit()it callsapp.findCapability<kke::INetworkReplicable>(), and — if and only if aDestructionModule(or anything else implementing the interface) happens to be in the sameApplication— gets a pointer to it back and can callserializeReplicatedState()on it every second. Remove theapp.addModule<NetworkModule>()line frommain.cppandDestructionModuleruns exactly the same; it just never gets asked.
NetworkModule here is honestly a stub — it measures payload sizes and
shows them in an ImGui panel, but never opens a socket. That's the right
scope for demonstrating discovery; a real transport is a separate, larger
piece of work (which peer is authoritative, reconciliation on conflicting
edits, packet loss/reordering) that deserves its own design pass rather
than being bolted onto this example.
Why the fixed-timestep tick matters for this: FixedUpdateContext::tickIndex
is a monotonically increasing counter driven by Application's fixed-rate
loop (60 Hz by default), not wall-clock time. Two machines running the same
seed and reacting to the same trigger tick compute the same fragment
positions at the same tick, regardless of each machine's actual frame
rate — which is what makes sending a tick number (instead of a timestamp,
instead of positions) a coherent thing to replicate at all. Physics and
animation should generally live in fixedUpdate() for the same reason,
even before networking enters the picture: reproducible replays,
consistent behavior independent of display frame rate, and a shared
"which simulation step are we on" number that a networking module can
reason about.
What's still not solved: a real transport (see above), and anything
resembling authority/reconciliation for two peers whose INetworkReplicable
state disagrees. deserializeReplicatedState() exists on the interface
and DestructionModule implements it correctly, but nothing in this repo
calls it yet — that's the natural next piece once an actual NetworkModule
opens a socket.
Why a custom particle system (not Effekseer/SparkEngine/etc.)¶
You asked me to look at Effekseer, SparkEngine, Momentous, MonoGame.Particles, Three.proton, and Sparkle, and pick the most robust — robustness over ease of use. Here's the reasoning:
- Effekseer and SparkEngine are authoring tools (Effekseer ships an editor and exports a proprietary effect format; SparkEngine is similarly a content pipeline) — they solve "let an artist build an effect," not "give this Vulkan engine a particle simulation primitive." Adopting either means adopting their file format and (for Effekseer) a separate runtime library with its own renderer abstraction to bridge into ours.
- MonoGame.Particles and Three.proton are tied to MonoGame (C#/XNA-style) and Three.js (WebGL) respectively — wrong language/API entirely for a native Vulkan C++ engine.
- Momentous (Fabian Giesen's DirectX Compute particle system) is the closest in spirit — GPU-compute-driven, no CPU particle array — but it's a demo/reference implementation for DirectX, with community OpenGL ports; there's no maintained Vulkan port, and adapting DX-compute-shader idioms through an OpenGL port to Vulkan's descriptor/barrier model would likely mean rewriting most of it anyway.
- Sparkle (C++14, OpenGL + GLSL compute) is architecturally the right shape — GPU simulation, compute shaders — but again OpenGL, not Vulkan; same rewrite cost as Momentous.
Given that every option requiring the least adaptation still meant either
adopting a foreign asset pipeline or rewriting an OpenGL/DirectX compute
system's Vulkan descriptor/barrier/pipeline plumbing from scratch, the
most robust fit — matching this engine's actual synchronization model
(explicit barriers, volk-loaded function pointers, VMA-managed buffers)
rather than adapting someone else's — was to write the compute→graphics
particle pipeline directly against this engine's own primitives. That's
what ParticleModule is: a storage buffer holding particle state, a
compute shader (shaders/particle.comp) integrating and respawning
particles in place every frame with no CPU readback, and a graphics
pipeline (particle.vert/.frag) reading that same buffer to draw point
sprites. It's the same architectural shape as Momentous/Sparkle, just
Vulkan-native instead of ported.
This isn't a closed decision — if a Vulkan-native, GPU-driven FOSS
particle library surfaces later, swapping it in only touches
ParticleModule; nothing else in the engine knows particles exist.
Logging¶
Uses spdlog (MIT licensed) — not
plain std::cout/std::cerr, which is what most of this engine used
until this was added. Application's constructor calls kke::log::init()
automatically (using the title you pass it as the game name), and its
destructor calls kke::log::shutdown(); a module just does:
kke::log::get("MyModuleName")->info("something happened: {}", value);
kke::log::get("MyModuleName")->warn("something's off: {}", reason);
Format: [timestamp][engine][module][game name][level]: message,
with the level and message colored by severity in a real terminal (info
green, warning yellow, error red — spdlog's own sane default, correctly
suppressed automatically when output is piped to a file rather than a
TTY, verified with both a piped run and a script-wrapped pseudo-TTY
run rather than assumed).
Non-blocking by design: every logger shares one background thread
and one bounded queue (spdlog::create_async_nb, async_overflow_
policy::overrun_oldest). A log call pushes a formatted message onto
that queue and returns immediately — the actual stdout write happens on
the background thread. Under sustained log spam the queue drops the
oldest buffered message rather than ever blocking the calling thread,
so a burst of logging can never stall a frame. This was a specific,
deliberate design goal, not an incidental spdlog feature we happened to
use.
Migration status, honestly: only a representative few call sites
(VulkanDevice, MarketplaceIndex) have been migrated from the old
std::cout/std::cerr calls scattered through the rest of the engine —
enough to prove the format, the async behavior, and the color output
all actually work, not a claim that every log line has been converted.
Migrating the rest is mechanical but real work — tracked in the Roadmap.
Default fonts / font fallback chain¶
Every game gets two fonts loaded automatically by UiModule before any
document loads, both bundled directly in this repo at assets/fonts/
(OFL-1.1 licensed — see assets/fonts/NOTO-LICENSE.txt), not fetched at
build time, so a fresh checkout always has working defaults with no
network dependency:
NotoSans-Regular.ttf— the primary text font. Noto specifically because its whole design goal ("no tofu") is broad Unicode coverage across many scripts, a reasonable universal fallback rather than a Latin-only choice.NotoColorEmoji.ttf— loaded as a fallback face (Rml::LoadFontFace(path, /*fallback_face=*/true)), so RmlUi reaches for it automatically for any character missing from Noto Sans. Nofont-familyfiddling needed in a document to get emoji — they just work alongside normal text.
On emoji, verified rather than assumed: color emoji glyphs use
bitmap strike formats (CBDT/CBLC), and it genuinely wasn't clear whether
RmlUi's default font engine would render those correctly or just tint
them like regular glyph coverage masks — so before bundling anything,
this was actually tested: loaded NotoColorEmoji.ttf, put 🎮 and 😀 in a
real document, rendered it, and sampled the actual output pixels with
Python/PIL rather than trusting a screenshot glance. The result was
unambiguous — genuine saturated colors came back (e.g. (255, 220, 34),
a real yellow, not a grayscale value tinted by the text color) — so
color emoji is bundled as a real, working feature, not a "should work in
theory" claim. One honest tradeoff: the file is ~10.8 MB (vs. ~0.5 MB
for Noto Sans) because it embeds bitmap strikes at multiple resolutions
— a smaller/subset variant is a reasonable future optimization, tracked
in the Roadmap rather than done preemptively.
A game that wants its own typography just calls Rml::LoadFontFace()
again with its own font file and family name — RmlUi keeps every loaded
face registered simultaneously, and CSS font-family on any element
picks between them normally. Nothing needs to be disabled or overridden;
the bundled fonts are a floor, not a lock-in.
Where the RmlUi Vulkan backend lives¶
RmlVulkanRenderInterface (engine/include/kke/RmlVulkanRenderInterface.h
+ .cpp) is the whole thing: geometry compiles into the engine's own
Buffer objects, textures (currently: glyph atlases only) get a real
VMA-backed VkImage + VkSampler + descriptor set, and untextured draws
share the exact same pipeline via a persistent 1×1 white texture rather
than a separate shader variant. shaders/rml_ui.vert/.frag do the
actual pixel-to-NDC transform and texture-times-vertex-color shading.
Test suite & coverage¶
Two different kinds of code get verified two different ways, on purpose — applying one blanket coverage number to both would either be trivially gameable or force mocking the entire Vulkan API before writing a single new feature:
- Pure logic —
GameManifestparsing,MarketplaceIndexdedup,escapeRmlText. No GPU, no window, deterministic. Real unit tests (tests/, GoogleTest), an enforced 85% line-coverage floor, and CI fails the build below it. - Vulkan/GPU-touching code —
Renderer,Pipeline, every module'srender(). Verified the way this whole project has been built from the start: actually compile it, actually run it headlessly against Mesa'slavapipesoftware Vulkan device, actually look at the resulting pixels or log output. That's a legitimate, different strategy — not a lesser one — and it's what CI's headless smoke-test step does on every push.
Real measured numbers, not a claimed target — from an actual run of the pipeline below, not asserted:
- Pure-logic files, unit tests only: 99.1% (114/115 lines,
tests/test_*.cpp, 33 tests, all passing — grew from 25 withEngineError's tests; percentages here are the last full measurement, not re-run after every subsequent addition — see the note above about CI being the source of truth going forward). The one "uncovered" line is a closing brace — a standard gcov line-attribution artifact, not a missed code path; every real statement is covered. - Whole engine (
engine/src/, including every Vulkan file), combining the unit tests and an actual headless run ofkke_demo(with mouse interaction — the destruction trigger button, a camera drag — exactly like the manual verification this project has done throughout): 94.0% (1505/1601 lines). Per-file: most render/logic files hit 100% (Pipeline.cpp,Mesh.cpp,GameManifest.cpp,MarketplaceIndex.cpp,ParticleModule.cpp, others); the lower end (Window.cpp75.9%,Buffer.cpp84.6%,UiModule.cpp82.6%) is mostly error-handling branches and paths that need a real GPU/resize event/validation-layer warning to trigger, not gaps in what's been exercised on the happy path.
These numbers will drift as code changes — CI is the source of truth going forward, not this paragraph. Re-run the pipeline below to get a current number rather than trusting this one indefinitely.
Running it yourself¶
# Unit tests (fast, no GPU needed)
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
./build/bin/kke_tests
# Coverage (separate build dir — don't mix instrumented and
# non-instrumented objects, it corrupts gcov data)
cmake -B build-coverage -G Ninja -DCMAKE_BUILD_TYPE=Debug -DKKE_ENABLE_COVERAGE=ON
cmake --build build-coverage -j
./build-coverage/bin/kke_tests
Xvfb :99 -screen 0 1280x720x24 &
DISPLAY=:99 SDL_VIDEODRIVER=x11 timeout 8 ./build-coverage/bin/kke_demo
lcov --capture --directory build-coverage --output-file coverage_full.info \
--ignore-errors mismatch,unused,negative,inconsistent,gcov,source
lcov --extract coverage_full.info "*/engine/src/*" --output-file coverage_engine.info
lcov --summary coverage_engine.info
genhtml coverage_engine.info --output-directory coverage_html # open coverage_html/index.html
One tooling note worth recording: this lcov version's --list
command showed per-file percentages wildly inconsistent with its own
--summary output on the exact same .info file (a few percent vs.
99%+) — a real anomaly, not a typo. Cross-checked against genhtml's
independent recomputation (which agreed with --summary exactly) and
the raw per-line hit records in the .info file itself before trusting
any number here. Use --summary/genhtml, not --list, in this setup.
CI pipeline¶
.github/workflows/ci.yml runs on every push/PR: plain build → unit
tests → headless smoke tests (kke_demo, physics_demo, melt_demo,
sandbox, rmlui_demo and synty_demo each run 8 seconds under Xvfb +
lavapipe without crashing or logging a fatal error; the Synty-based
ones without assets, which they must survive) → coverage-instrumented
build → unit tests again → headless demo run again → lcov/genhtml →
coverage reported as a warning annotation. The 85% floor is paused
during the POC phase (2026-09-26): new GPU-side modules are covered by
the smoke runs, not the instrumented kke_demo run, so every feature
lowered the number and failed CI with nothing broken (78.7%). It comes
back as a ratchet at alpha. Push CI runs for main only (every commit
also goes to a working branch, which doubled every result), and a newer
push cancels an older run. Now genuinely
confirmed running on GitHub's real infrastructure, not just validated
by hand locally — and the first real run found a real bug in the
workflow script itself (a bash -e/errexit gotcha in the smoke-test
step's exit-code handling, not the engine), found by reading the
actual failure log and reproducing it locally before fixing — see
"Immediate next slices" further down for the full account.
What this doesn't cover yet¶
- Injection/MITM-specific test scenarios beyond
escapeRmlText's unit tests. The escaping function itself has real adversarial test cases (element injection, attribute breakout — seetest_rml_text_safety.cpp). There is no man-in-the-middle test because there is no network transport yet to intercept (see Roadmap) — that's a test suite for a system that doesn't exist yet, not a gap in testing one that does. When a realNetworkModuletransport lands, it needs its own adversarial tests (tampered packets, replay, a fuzzeddeserializeReplicatedState()) before "man-in-the-middle" is a meaningful test category rather than a placeholder. - Fighting the two-tier split itself. As more pure-logic modules
appear (a real physics module's math, procedural generation seeds),
they should get the same unit-test treatment and count toward the 85%
floor; as more Vulkan modules appear, they extend the headless
smoke-test's coverage contribution the same way
kke_demoalready does. Neither tier is finished — both grow with the engine.
Physics: AMD FEMFX integration¶
FEMFX (AMD, GPUOpen, MIT/
MITx11 licensed) is vendored — patched — at external/FEMFX/, gated
behind KKE_ENABLE_FEMFX (default OFF). It's a multithreaded CPU
library for deformable material physics via the Finite Element Method:
tetrahedral meshes with per-element material parameters controlling
stiffness, volume resistance, and stress limits where fracture or
plastic (permanent) deformation occur. Chosen specifically because it
covers cloth/cushions (squishy volumetric soft bodies), impact
deformation, density-driven destruction (wood vs. metal vs. glass is
its own flagship description), and melting (explicitly supported as a
"change material parameters at runtime" case) in one coherent design,
rather than stitching several unrelated libraries together.
This required real, verified porting work — not a drop-in fetch¶
FEMFX only ever shipped for Windows/MSVC. Getting libfemfx.a to
build, link, and run on Linux/GCC took roughly 30 individual fixes,
found and verified one at a time by actually rebuilding after each —
starting from 1,871 compile errors down to 0. In rough order of
impact:
__forceinline(MSVC-only keyword, no GCC equivalent) — the single dominant cause, responsible for ~1,600 cascading errors on its own. Fixed via-D__forceinline=inline.__declspec(align(N))used inconsistently — FEMFX's ownsse_mathfun.halready shows the correct#ifdef _MSC_VER/__attribute__((aligned(N)))pattern; it just wasn't applied everywhere. Fixed withalignas(N)(portable C++11) where a prefix modifier was needed.- GCC's stricter standard conformance, in five different files: a
missing
typenamebefore a dependent type name (twice — including one genuinely confusing case wheretypenamewas incorrectly applied to a function call rather than a type,T::SoaMatrix3::scale(...)), a narrowing-conversion error on a hex literal, a template forward-declaration with the wrong number of parameters (template<class T>declared,template<class T, class CompareClass>actually defined and used everywhere). - MSVC's named union-member SIMD access (
.m128_f32[i],.m256i_i32[i], etc.) — real MSVC API with no GCC equivalent (GCC/Clang's__m128/__m256are opaque intrinsic types). ~45 call sites across 5 files, rewritten to use the portableSimd128Union/Simd256Uniontypes that were already sitting, unused, in FEMFX's own code. - Missing standard headers (
<cstring>,<cstddef>) in 4 files. - Genuine bugs in FEMFX's own original code, unrelated to
portability, found along the way: a copy-paste variable-name
mismatch (a function parameter named
pnt, body referring tovec), a missing-mfmacompiler flag for an FMA intrinsic actually being called,fopen_s(Windows-only) with no portable fallback, and three#includepath case mismatches (silently tolerated by Windows' case-insensitive filesystem, fatal on Linux's). - Linux atomics — 8 functions ported from
Interlocked*to__atomic_*builtins, with return-value semantics matched precisely to what MSVC's documentation says each one returns (particularlyFmAtomicCompareExchange/FmAtomicWrite, which both return the previous value, not a boolean or the new value).
Verified genuinely complete, not just "it compiled": nm on the
resulting archive confirms 639 real exported functions, including the
actual public API entry points (FmSetupScene, FmSceneConstraintSolve)
documented in FEMFX's own header. A real external program was written,
compiled against it, linked, and run successfully before considering
this done. All of that was then confirmed a second time inside this
engine's own CMake build (external/FEMFX/CMakeLists.txt) — not just
the standalone premake build used to originally find and fix the
errors — since a working standalone build and a working integrated
build are two different claims. One real mistake caught in that second
pass, worth naming honestly: the CMake translation initially forgot to
carry over -D__forceinline=inline (only copied WIN32/NOMINMAX),
reintroducing the dominant error category — caught immediately by
actually attempting the build rather than assuming the translation was
correct.
PhysicsModule — a real tetrahedron, genuinely simulating, falling under real gravity¶
kke::PhysicsModule (engine/include/kke/modules/PhysicsModule.h) is
a real Module wrapping FEMFX's FmScene, with one real tetrahedron
(4 verts, 1 tet, no fracture) added to it. Genuinely verified, not
just "compiles": confirmed running 20+ real seconds with zero
crashes across multiple full runs, and the object visibly falls under
real gravity — from height 5.0 down to near zero, at a rate matching
real free-fall physics (~1.24m drop in 0.5s vs. a theoretical 1.235m
for g=9.88, confirmed numerically in a standalone test before trusting
the integrated result).
Getting here took real, gdb-traced debugging, not guesswork — the
full account, because this is exactly the kind of thing worth being
precise about rather than glossing over. Four distinct issues, three
fixed, one still open but non-blocking:
- Fixed: a missing
FmInitConnectivity()call. FEMFX's own header walkthrough jumps straight from buildingvertIncidentTetsarrays toFmFinishConnectivityFromVertIncidentTets()without showing this call — following that literally left the mesh's sparse stiffness-matrix row structure never built.gdbconfirmed the crash:FmAddRowSubmatriceswithrowSize=0. - Fixed (real, but not the direct cause of #3): a SIMD ABI
mismatch.
femfxis compiled with-mavx2 -mfma;kke_engine— which compilesPhysicsModule.cpp, inlining many of FEMFX's own AVX2/FMA header functions directly — wasn't. Fixed by matching the compile options exactly (engine/CMakeLists.txt). - Fixed — this was the actual bug behind the worst symptom:
calling
FmFinishConnectivityFromVertIncidentTets()a second time afterFmInitConnectivity(), not realizing the latter already calls the former internally. Calling it twice doubledtetMesh->numExteriorFaces(4 real faces counted as 8), which crashedAMD::FmBuildHierarchy's BVH-rebuild code writing past the end of anodesarray correctly sized for 4. Two earlier mitigation attempts — disabling self-collision, disabling sleeping — legitimately failed to fix this, because neither was the real cause; removing the redundant call was. This same bug is also almost certainly why an earlier fall-rate measurement looked physically wrong — one fix resolved two separate-looking symptoms, confirmed by the corrected fall rate now matching real physics closely. - Resolved by adding a real ground plane (a static/kinematic
FmRigidBodybox, top surface at y=0) — a good suggestion that turned out to directly explain this: the object now falls and settles at ~0.002 above the floor, which is exactly the expected resting height (floor surface plus a small collision-contact gap). One genuine curiosity remains, noted honestly rather than glossed over: this is the identical value that appeared with no floor in the scene at all, in earlier testing. Not investigated further — the physically correct setup (with a floor) now works and is verified, which is what matters going forward.
Two things FEMFX requires the application to provide, both implemented
in PhysicsModule.cpp, both genuinely verified with a standalone test
program before being wired into the engine:
- A task system. FEMFX hardcodes
FM_ASYNC_THREADING=1— even a single-worker-thread scene needs a real implementation of a ~7- function callback interface (submit task, create/wait/trigger a sync event); there is no built-in "just run synchronously" mode. Rather than porting FEMFX's own ~1,800-line threaded sample task system (real, substantial, Windows-oriented code that would need its own portability pass), this implements a genuinely synchronous adapter: "submit a task" means "call it immediately, on the calling thread." Honestly not real multithreading yet — parallelizing this is real future work, tracked in the Roadmap — but a fully valid, fully verified implementation of the required interface. FmAlignedMalloc/FmAlignedFree— an allocator hook FEMFX declaresexternat global scope (verified, not assumed: qualifying them asAMD::FmAlignedMallocproduced a real compile error — "should have been declared inside 'AMD'" — which is what confirmed the declaration is global) and expects the application to define. Implemented viastd::aligned_alloc.
kke::Material — wired onto FEMFX, verified through a stable running simulation¶
engine/include/kke/Material.h defines the engine-level vocabulary
every deformable/destructible object should eventually read from —
density, stiffness, Poisson's ratio, fracture stress threshold,
plastic yield threshold and creep — deliberately not FEMFX-specific
naming, so a future second physics backend (or the melting/MPM track)
shares the same idea of what these numbers mean. The falling
tetrahedron's material (density 700, stiffness 1e7 — "wood-ish") flows
through this struct into FmTetMaterialParams, and that whole path is
now verified through an actual stable, 20+ second running simulation,
not just mesh setup.
Scaling: worst cases, multiplayer, platforms¶
SCALING.md answers "does a volcano level with 40 players on PCs,
phones and browsers perform?" with measured numbers (FEMFX costs
~0.15-0.2 ms per awake body per step on one core; a rock storm needs a
rigid-body layer) and the build order for what's missing: rigid bodies,
instancing/LOD/split screen, networking, platforms. The numbers come
from tools/physics_lab (kke_physics_lab volcano|shoot|fracture), a
headless FEMFX harness that needs no window or GPU.
Content pipeline: turning meshes into physics volumes¶
FEMFX simulates tetrahedral meshes. The engine builds them at runtime
from any render mesh with kke::voxelizeToTets (VoxelTets.h): the
triangles are rasterized into a grid fitted to the mesh's bounds, the
outside is flood-filled so open, non-watertight meshes (most Synty
props) still produce a solid, each solid cell becomes 6 tetrahedra,
and kke::fitSurfaceToMesh pulls the outer vertices onto the real
surface. A typical prop takes ~12 ms end to end, including fracture
patterns and gluing the render mesh on (see the sandbox).
.ktet.json (kke::loadTetMeshFromFile, TetMeshAsset.h) stays as the
on-disk format for cooked tet meshes, for when asset cooking lands.
History: until 2026-09-26 this section described kke_tetrahedralizer,
an offline CGAL-based command-line tool that turned closed meshes
into .ktet.json. It was removed: the runtime voxelizer handles every
mesh it did plus the open ones it rejected, its only consumer was a
hard-coded demo button, and CGAL's meshing code is GPL — a licensing
risk for an engine meant to power a marketplace. It is in git history
if a high-quality conforming tetrahedralization is ever needed as an
offline cooking option.
The demo suite¶
Beyond kke_demo_game (the general building-block showcase) and
games/physics_demo (see below), there are now dedicated demos for
individual capabilities, matching the same "one focused demo per
thing, verified visually before moving on" discipline throughout.
games/imgui_demo¶
Wraps Dear ImGui's own built-in ImGui::ShowDemoWindow() rather than
hand-curating a widget list — confirmed before building this that
imgui_demo.cpp is already compiled into this engine's imgui target
(see root CMakeLists.txt) and nothing disables it. This is genuinely
the canonical, comprehensive answer to "show me everything this UI
library can do," maintained by ImGui itself: every widget type
(buttons, sliders, color pickers, drag/drop, tables, trees, tabs,
menus, popups, text editing, plotting), all in one place. Verified
interactively, not just "the window opened": clicked into the
"Widgets" section and confirmed it expands to the real, full category
list (Basic, Tree Nodes, Text Input, Tabs, Plotting, Drag and Drop,
and more). Always built (no KKE_ENABLE_* gate needed — no
dependency beyond the core engine).
games/rmlui_demo — the UI showcase¶
A selectable set of real game screens, each a plain .rml/.rcss file in
games/rmlui_demo/ui/ bound to C++ state (ShowcaseModule) through
RmlUi data models. The nav bar at the top switches screens; Esc
returns to the main menu, F1 toggles the ImGui developer overlay, F5
reloads stylesheets.
| Screen | What it shows off |
|---|---|
| Main menu | Staggered entrance animations, hover transitions, radial-gradient vignette over a live 3D scene, modal dialogs (Credits, Quit → really quits), @media rules for small windows. |
| Settings | Every control is data-bound straight to kke::EngineSettings and actually changes the engine live: fullscreen, VSync, frame-rate limit, FOV, shadows, brightness, UI scale, developer overlay, mouse sensitivity/invert, key rebinding (click, press a key), difficulty, physics catch-up steps. Apply & save writes settings.json; Revert/Defaults work; "unsaved changes" indicator. Tabs, checkboxes, radios, sliders, dropdown, scrolling. |
| Inventory | Drag & drop between slots, equipment slots that refuse the wrong item type, stacking, rarity borders, filter chips, sort, hover details panel, double-click to consume, weight bar with gradient that turns red when heavy. Item icons are color emoji — no image assets needed. |
| HUD | Health/mana/stamina/XP bars with a lagging "ghost" damage bar, hotbar with cooldown sweeps (keys 1–6), floating damage numbers, hit flash, minimap with a conic-gradient radar sweep, compass, quest tracker, toasts. pointer-events set so only the interactive parts take clicks. |
| Dialogue & chat | Typewriter NPC text with a branching conversation tree and numbered choices; a chat log with text input where player text is inserted as text, never markup. |
| Loading | Animated spinner, gradient progress bar, step checklist, rotating tips, "press any key". |
Run it: cd build/bin && ./rmlui_demo. Iterate on one screen with
KKE_SHOWCASE_START=inventory ./rmlui_demo; edit the source .rcss
live with KKE_UI_ROOT=../../games/rmlui_demo/ui ./rmlui_demo and press F5.
What the engine gained to make this possible (all in engine/, usable by
any game): dp-based scaling by window height × UI scale; RmlUi ticking
while paused; SDL3 text input; HiDPI mouse mapping; UI mouse capture so
clicks don't also move the camera; CSS transforms, CSS gradients
(linear-/radial-/conic-gradient) and stencil clip masks in the Vulkan
backend; correct sRGB + premultiplied-alpha color; bold/italic fonts;
kke::EngineSettings (+ unit tests) and kke::SettingsModule, which
applies settings through ISettingsListener. Still not implemented in
the backend: RmlUi filters and layers (filter: blur(),
box-shadow, drop-shadow), which need render-to-texture — those
properties are silently ignored for now.
games/synty_demo — Synty assets, skinning and bones¶
Loads Synty POLYGON FBX files directly (via kke::loadModel, backed by
ufbx) and builds a small level from the Prototype pack: floor tiles,
walls, stairs, crates, barrels, trees, barrier, flag. Four skinned
characters stand in the middle, each doing something different — the
FBX's own animation clip, a procedural wave, idle breathing, and a
hand-posed skeleton you edit bone by bone from the Characters panel.
B toggles the bone overlay (drawn on top, lined up exactly with the
skinned meshes).
Ragdolls (FEMFX build): R ragdolls the selected character with a
shove, Shift+R knocks everyone over, T stands them back up, and
G stands a FEMFX glass pane in front of the selected character and
throws them through it — rigid-body ragdoll meets fracturable deformable
body; the pane shatters (~57 pieces) and everything goes to sleep once it
settles. The character side only talks to kke::IRagdollPhysics
(Capabilities.h), so another physics module could provide ragdolls
without the character code changing. The ragdoll itself is built from the
skeleton by kke::buildHumanoidRagdoll() (Ragdoll.h, unit-tested):
11 boxes (pelvis, torso, head, upper/lower arms, thighs, calves), ball
joints, hinge knees, starting from whatever pose the character is in.
Current limits, from FEMFX itself: limbs don't collide with each other
(its built-in rigid solver has no rigid-vs-rigid contacts — AMD's samples
disable them too) and there are no cone/twist limits, so limbs can
over-rotate. Ragdolls do collide with the floor and FEMFX objects.
The pack is never committed (licensed per user): put the extracted pack
folder(s) in assets/synty/ (any layout) or set KKE_ASSETS_DIR — see
assets/README.md. Without it the demo runs and lists where it looked.
Engine pieces this added, usable by any game:
- kke::loadModel() (ModelAsset.h) — FBX/OBJ to meshes, materials,
skeleton, skin weights and sampled animation clips; meters, +Y up.
Resolves textures the file points at with an artist's own path
(U:/Dropbox/...) by file name, also trying .png/.tga for
.psd references, with an optional fallback texture. Unit-tested,
including against the real Synty character when installed.
- kke::ModelModule — cached loading, instances, lit/textured/shadowed
drawing, CPU skinning, clip playback, per-bone posing
(boneLocals()), and setBoneWorldOverride() for physics-driven
skeletons.
- Pieces are placed by their bounds (Synty pivots vary: building parts
at a corner, props at their center).
games/sandbox — build with your packs, then break it¶
cd build/bin && ./sandbox. Finds your asset packs (same rules as the
Synty demo: assets/synty/, KKE_ASSETS_DIR, or type a folder into the
Assets panel, which also lists everywhere it looked) and lists every
model in them.
- Assets panel: filter by pack, category (from Synty's name prefixes)
or search; click an asset, then click in the world to place it.
"Pictures" shows a grid of thumbnails (
kke::ThumbnailModule): each model is rendered once (lit, framed by its bounds) into a 128 px offscreen target with the main pass's attachments, copied into a 2048 px atlas ImGui draws directly, and saved as a PNG per pack in your cache folder (KKE_THUMBNAIL_CACHE, else~/.cache/kk-engine/thumbnails,%LOCALAPPDATA%\kk-engine\thumbnailson Windows; never in the repo: they're renders of licensed packs). Files are read on a worker thread, at most 2 renders and 16 cached pictures a frame, and only what's on screen is asked for, newest first. Animation-only files show "(anim)". Measured on the 3,070-asset test catalog with the software renderer (llvmpipe): the list scrolls at ~125 fps, the grid at ~100-110 fps, and the same while thumbnails are being made or come from disk. - Look: the pack's texture variants (Prototype's
_Texture_01..10recolours) for new objects, per object, or all at once; the world grid overlay (Prototype's_Grid_*patterns, tile size, strength) — saved with the layout. - Placing: snaps to the grid (0.25–5 m, or off); lands on top of
whatever is under the cursor, so crates stack.
R/Ctrl+wheelrotate,Shift+clickkeeps placing,Escstops. - Editing: click to select,
Shift+clickadds to (or takes out of) the selection,Ctrl+Aselects everything. Drag the gizmo to move along X/Y/Z, rotate around Y or scale (Tabswitches; snaps to the grid / rotate step / 10 %, holdShiftfor free).Gmove,Rrotate (a group turns around its centre),Ctrl+Dduplicate (the copies follow the mouse;Esctakes them away again),Deldelete. Position, yaw, scale and collision can also be typed in the inspector. Every edit can be undone:Ctrl+Z,Ctrl+Y/Ctrl+Shift+Z(100 steps; undo puts back exactly what changed, so a broken prop elsewhere keeps its pieces). - Levels:
Ctrl+S/Ctrl+Lsave/load a kke.scene (SCENES.md,kke/SceneFile.h), by defaultscenes/sandbox.scene.json, so kke_demo lists it in its Scenes panel (KKE_SCENE=sandboxstarts in it) and you walk it with Jolt collision per object: mesh, box or none. Saved with the level: name, player spawn ("Spawn here", cyan box), sun, ambient and up to 2 point lights, texture variants, breakable materials and fracture seeds, and a floor under everything. Older sandbox layouts still load.KKE_SANDBOX_LAYOUT=fileloads one at startup,KKE_SANDBOX_SAVE=filesaves right after (converting). - Camera (editor controls): right-drag orbit, middle-drag pan, wheel zoom, WASD/QE move, Shift faster.
- Physics (FEMFX build):
Kragdolls/stands up a selected character;Xturns a selected prop into a breakable physics object (Wood, Stone, Glass, or Metal that dents); the Shoot tool (2, then left-click),ForSpacethrows a ball at the cursor (6 at most, oldest removed).F1shows the engine's debug panels.
How breakable props work (engine pieces, reusable by any game):
1. kke::voxelizeToTets turns the prop's own triangles into a tet volume
(cells fitted to its bounds; works on open, non-watertight meshes),
and kke::fitSurfaceToMesh pulls the outside onto the real surface.
2. kke::fractureChunks groups tets by the material's pattern — wood
splinters along its longest axis, stone breaks into Voronoi
chunks, glass shatters radially, ceramic into shards, metal
is solid and dents (plasticity). kke::fractureFlagsFromChunks
bakes that into FEMFX face flags, so cracks can only run between
chunks. "Pattern" in the panel overrides the material's own.
3. The prop's mesh is subdivided and each triangle glued to the tet under
it (kke::embedTriangles); PhysicsModule::deformEmbedded moves it
every frame the physics is awake, so the prop bends, dents and breaks
as itself (its texture, variant and grid). Fresh crack faces are
drawn in the prop's own colours.
4. Props arm 0.75-2 s after spawning, once settled: only stress added
by a hit breaks them (BUGS.md BUG-043).
Setup costs ~12-17 ms per prop. Honest limits: inside the sandbox, placed static meshes don't collide with FEMFX (balls and debris only hit the ground, physics objects and ragdolls; the games that load the level do collide, through Jolt), so a crate on a broken one stays floating; no asset thumbnails yet (the renderer can't draw into an ImGui image); debris is slow to fall asleep, which keeps one core busy (~10-20 ms/step for ~70 pieces) until it does — next on the physics list.
games/sea_demo — a boat, the sea, float or sink¶
cd build/bin && ./sea_demo. Drive the boat with the arrow keys,
left-click to throw things overboard (1-5 picks: foam, wooden
crate, sealed barrel, ice, iron), C toggles the follow camera, R
resets. Wind speed, direction and choppiness shape the swell.
How it works (the approach most games use — "feels right", cheap):
- kke::OceanWaves — 4 Gerstner waves with real deep-water dispersion,
generated from wind. The CPU evaluates exactly what ocean.vert draws
(128 bytes of push constants, no textures), so floating things ride
the visible waves.
- kke::FloatingBodies — rigid boxes sampled by points; each submerged
point gets Archimedes' buoyancy and drag against the moving water, at
the point, so tilting creates righting torque on its own. Density
decides: water is 1025 kg/m³. Heave damping (radiation damping, the
cheap way) and a low centre of mass (ballast) keep the boat from being
launched off crests or capsizing — both found by testing, see BUGS.md
BUG-044.
- kke::OceanRenderer — camera-following grid (snapped so it doesn't
swim), Fresnel sky reflection, sun glint, crest foam, horizon haze,
gradient sky with a sun.
Honest limits: the sea is opaque (no refraction, underwater parts hidden), bodies are boxes (Synty boats/props as floaters: next), no FEMFX objects float yet, spray is simple ballistic particles.
games/jiggle_demo — jiggle physics¶
cd build/bin && ./jiggle_demo (Tab switches scenes). A strawberry
jelly with balls raining onto it (they dent it, it throws them back), and
a Synty character given curves and soft-tissue bones that jiggle as she
jogs, jumps and stops on UAL clips, with an optional twin without jiggle
to compare. Engine pieces: kke::JiggleRig, kke::JiggleSkin,
kke::JellyBody, kke::addHumanoidSoftTissue — see JIGGLE.md.
games/audio_demo — every audio case, one station each¶
cd build/bin && ./audio_demo (headphones recommended). Ten stations on
one long field: open field, small stone room, great hall, padded room,
the same knock behind wood, glass and stone, a sound round through a door,
falling crates of six materials, footsteps on six grounds, a tick circling
your head (binaural switched on there) and navigation pings. You stand at
the station's listening spot; turning the camera turns your ears. The
panel says what to listen for, shows what the engine measured (room RT60,
reverb, openings, how much got through the walls) and has switches for
reverb, muffling, openings and binaural. Walls and floors are Jolt bodies
built from boxes in code: no assets.
KKE_AUDIO_DEMO_TOUR=1 KKE_AUDIO_DEMO_EXIT=1 KKE_AUDIO_RECORD=tour.wav
visits every station, logs the numbers per station and saves the 80 s mix.
First tour (2026-09-26, headless) after the realism work: open field
reverb 0; small stone room RT60 0.9 s, reverb 40%, 8 echoes; great hall
RT60 2.2 s; padded room RT60 0.25 s, reverb 15% (was 50% before surfaces'
softness counted); behind wood 0.30, glass 0.50, stone 0.07; the sound
outside came through the door 5 times out of 5 (0 before the level ring
and turning probes); 42 crate impacts; 18 footsteps.
Steam Audio as an optional HRTF backend (2026-09-26)¶
-DKKE_ENABLE_STEAM_AUDIO=ON downloads Valve's Steam Audio SDK (v4.6.1,
Apache-2.0, hash-pinned, licence fetched beside it) and adds
kke::SteamAudioSpatializer: every spatial voice through its own
IPLBinauralEffect on the default or a SOFA HRTF, behind a small
kke::Spatializer interface in the mixer. Mode "HRTF" in the Audio panel
and the audio demo, KKE_AUDIO_HRTF=1|file.sofa. Loudness matched to the
built-in model (measured from five directions), 5.3 ms of latency from
the frame FIFO, falls back to Binaural with the reason logged if it can't
start. The audio demo's tour ran with it (headless, recorded); 7 tests.
Off by default (a 150 MB download), and not in CI yet.
games/melt_demo — pour lava, melt things¶
cd build/bin && ./melt_demo (KKE_MELT_PRESET=0..3 picks the block).
Lava pours from a spout onto a block of ice, wax, chocolate or aluminium.
Ice melts into water that runs off and quenches the lava into crust; wax
and chocolate slump and harden again as they cool; aluminium glows before
it melts. Space toggles the pour, R resets.
Engine pieces (reusable, unit-tested, render-agnostic):
- kke::ParticleFluid — Position Based Fluids (Macklin & Müller 2013):
counting-sorted spatial hash, per-substep neighbour lists, per-particle
temperature (diffusion, cooling), per-material viscosity and
solidification (lava crusts below 550 °C), SDF colliders, a particle
budget. ~2,000 particles in a few ms on one core.
- kke::MeltVolume — a voxel solid with temperature: heat from touching
liquid, conduction, latent-heat melting, melted matter re-emitted as
liquid particles, a chamfer signed-distance field for collisions, and a
surface by marching tetrahedra (no lookup tables, watertight).
- kke::SphereImpostorRenderer (lit sphere impostors with real depth and
incandescent glow) and kke::DynamicMeshRenderer (CPU-updated meshes).
- kke::FluidSurfaceRenderer — screen-space fluid rendering (van der
Laan et al. 2009, the approach behind NVIDIA Flex's liquids): particles
into an offscreen linear-depth + colour target, a bilateral blur in
compute (fixed size in world units, stops at depth jumps), then a
full-screen pass that rebuilds the surface, lights it and writes real
depth. The particles read as one continuous liquid. L toggles back to
the raw spheres for comparison; "Smoothing" sets the blur radius.
Uses the new Module::prepass() hook (offscreen work every drawn frame,
between the shadow pass and the main pass).
Honest limits: the smooth surface is opaque (no thickness-based transparency or refraction yet) and shows some vertical streaking from the separable blur; no steam/boiling; values are tuned to feel right, not measured. FEMFX has no liquids, so this is separate from it; the old physics_demo "Lava Melt" scene only remains in the scripted benchmark.
games/physics_demo — a dedicated demo, because the shared one couldn't show this legibly¶
kke_demo_game's render bridge worked, but was genuinely hard to
see: a single small tetrahedron at a render scale (0.02) tuned to fit
a camera built for a unit cube, not real physics content. Rather than
keep tuning that mismatch, games/physics_demo is a real, separate
game folder with its own camera and its own scale — OrbitCameraModule
and PhysicsModule both gained constructor parameters
(initialDistance/initialPitch/initialYaw/initialTarget, and
renderScale/initialObjectCount) specifically to make this possible
without duplicating either class.
Two real CMake conflicts hit and fixed while building this, both
found by actually building a second executable, not anticipated:
1. Two executables compiling the same shader file collided on a
global CMake target name (shader_rml_ui_frag already exists).
Fixed by guarding engine_add_shader with if(NOT TARGET ...).
2. Two executables copying the same bundled font to the same output
path hit a Ninja "multiple rules generate the same output" error —
the copy-file helper had been duplicated as a separately-named
function per game. Fixed properly: refactored into one shared
engine_copy_runtime_file function at the root CMakeLists.txt,
used by both games now instead of two divergent copies of the same
logic.
The ground plane needed its own fix, and it was the exact same bug
class as before, at a new scale. renderScale=1.0 (real physics
units — a 100-unit floor) reproduced the old "floor fills the entire
screen with one flat color" issue, confirmed by the same bisection
technique used the first time: disabling just the ground draw fixed
the view immediately, isolating it before touching anything else.
Fixed by clamping the ground's visual width independently of
renderScale (0.5 to 10 units) rather than letting it scale
unboundedly — verified to leave kke_demo_game's already-working
2-unit floor (100 × 0.02) completely untouched, since 2 is well under
the clamp, while giving physics_demo's real-scale floor a
proportionate, legible size instead of 100 units. The actual physics
collision volume is unaffected either way — this only ever changes
what gets drawn.
Verified visually, not just logically: real screenshots at each step — first with the ground disabled entirely (proving the six falling tetrahedra themselves render correctly, clearly distinguishable by color and shape), then with the clamp at increasingly smaller values until the floor read as a floor rather than a wall of color. A
A real, reported "objects float above the floor" bug, found after
that — a second mistake in the same block of code, not a leftover from
the first fix. The ground's Y translation was computed BEFORE its
thickness was clamped down, using a fixed -0.5 * renderScale
regardless of how thin the clamp then made the box — at
renderScale=1.0, that left the rendered floor's top surface at
y=-0.475, while an object actually rests at y≈0.002 (the real
physics contact surface). A visible ~0.475-unit gap between where
objects visually landed and where the floor was drawn, exactly
matching what got reported. Fixed by computing the thickness first and
deriving the translation from it, so the rendered top surface is
always exactly y=0 regardless of how the clamp scales the box.
Verified with real screenshots before and after — objects visibly
resting flush on the floor now, not floating above it — and a
regression check against kke_demo_game's own much smaller
renderScale=0.02 ground confirmed no change there.
regression screenshot of kke_demo_game afterward confirmed no visual
change there, and — a genuine bonus, not engineered for — its
Marketplace panel now shows "# 2 games," correctly auto-discovering
physics_demo's game.json through the same scanning this engine
already had.
The tetrahedron and a ground plane are now actually drawn, not just logged — reusing the existing cube shaders directly (they just transform a position by an MVP matrix and output a flat color, which is exactly what a physics-driven mesh needs too) and a raw, per-frame- uploaded vertex buffer for the tetrahedron's 4 live positions, since they genuinely move and deform.
Getting a correct picture on screen took real, screenshot-verified debugging — five rounds of "that's not it," not a straight line, and worth recording honestly:
- First attempt drew both objects at their true physics scale (a 100-unit-wide ground, an object falling from height 5). Result: the entire 3D viewport filled with one solid flat color, every frame, for every existing module (cube, grid, particles included) — not just the new ones. Alarming, and wrongly assumed at first to be pipeline/shader corruption.
- Bisected step by step with real screenshots at each stage: disabling just the draw calls (keeping resource creation) rendered fine — ruled out pipeline/buffer setup. Disabling just the tetrahedron draw (keeping only the ground) still reproduced the full-screen fill — isolated it to the ground plane specifically. Removing the ground's extreme scale entirely rendered a small, correctly-colored, correctly -positioned cube — proof the geometry, shaders, and pipeline were never actually broken.
- The real explanation, found by checking
OrbitCameraModule's actual math rather than continuing to guess: its default camera sits attarget - forward * distancewith a positive pitch, placing the eye at roughly y=-1.68 — below world y=0 — looking up and forward. A physics ground plane sized for real gravity (100 units wide) is enormous relative to a camera framing a unit cube at distance 3.5; viewed from below at that scale, it fills the entire upward-tilted view with one face's flat color. Not corruption — correctly rendered geometry, just wildly mismatched in scale to the camera that happened to already exist. - First fix attempt pushed the whole rendered scene below the camera's eye height to stop it from overwhelming the view — which instead made it disappear entirely, since this camera only ever looks upward and forward, never down. Wrong direction, caught immediately by looking at the resulting screenshot rather than assuming the fix worked.
- Actual fix: a much smaller
kRenderScale(0.02) — keeping the rendered objects near y=0, where the camera already frames the existing demo cube successfully, just much smaller than their true physics-unit size. The simulation itself is completely untouched by any of this;kRenderScaleinPhysicsModule.cppis render-time- only. (An earlier, ruled-out attempt also tried a vertical offset constant — removed entirely once the real fix was found, rather than left behind unused.)
Current state, accurately: a ground plane and the falling tetrahedron are both visible, proportioned reasonably against the existing demo cube, no corruption, verified via actual screenshots across a running session — but this is a first pass, not a polished result. The tetrahedron is small and can be hard to visually distinguish from the ground/cube at this scale and the existing demo camera's default framing. A dedicated physics demo with its own camera setup (part of the demo-suite work already planned) is the right place to make this genuinely legible, not further tuning of these two constants.
Real multithreading — a real thread pool, replacing the synchronous stand-in¶
The task system callbacks used to be a genuinely synchronous
stand-in: "submit a task" meant "call it immediately, on the calling
thread." This is now a real thread pool, verified standalone before
being wired into PhysicsModule, matching this whole project's
established discipline.
A real correctness risk, found by reading FEMFX's own source
directly, not assumed: GetTaskSystemWorkerIndex()'s return value
indexes straight into a per-worker scratch buffer array
(scene->threadTempMemoryBuffer->buffers[workerIndex], confirmed in
FEMFXSimulate.cpp), and that array is sized to exactly
numWorkerThreads (confirmed in FEMFXThreadTempMemory.cpp) — not
numWorkerThreads + 1. Two threads returning the same index would
silently race on the same memory. The pool reserves index 0
permanently for the main thread and gives real pool threads indices
1..N-1, so every possible caller — whichever thread FEMFX's own
internal task-chaining ends up running work on — has a stable, unique
slot for as long as that thread exists.
A real deadlock, found by hitting it, not anticipated: with
numWorkers==1 (a real, legitimate configuration — every prior
verified run in this class's history used it), the pool creates zero
real worker threads. Queuing a task in that configuration hangs
forever under a real timeout, since nothing would ever service the
queue. Fixed with a synchronous fallback specifically for that case —
"run inline" when there's no pool thread to hand work to, matching
exactly what the old stand-in always did.
Correctness verified, not just "it didn't crash": a standalone
test ran the exact same scene with 1 worker and with 4 workers and
compared the final simulated position — identical both times
(0.2276), strong evidence the worker-index scheme doesn't corrupt
anything. Confirmed 3 distinct OS threads genuinely executed submitted
tasks (not just queued-and-ignored). Integrated into PhysicsModule
(replacing the stand-in entirely, sized via
std::thread::hardware_concurrency() with a guard for the "0 means
unknown" case the standard allows), rebuilt clean, all 40 tests still
pass, and both physics_demo and kke_demo_game produce byte-
identical settling behavior to every prior verified run.
The one honest, important limit on what could be verified here:
this sandbox has exactly 1 CPU core (nproc == 1). Real speedup
cannot be demonstrated in an environment with no second core for
parallelism to use — confirmed directly: 4 workers measurably ran
slower than 1 in this environment, exactly the pure thread/sync
overhead you'd expect with zero real parallelism to offset it.
tools/physics_benchmark (kke_physics_benchmark, gated by
KKE_ENABLE_FEMFX) exists specifically so this can be measured
honestly on real multi-core hardware: it runs the same scene with 1
worker and with hardware_concurrency() workers back-to-back, checks
that both produce the same result, and prints a clear speed
comparison — including an explicit note if it detects it's running on
a single-core machine, rather than reporting a misleading number.
Usage: kke_physics_benchmark [numTets] [numSteps] [forceMultiWorkers].
What's not done yet¶
- ~~The task system is genuinely single-threaded.~~ Fixed — see "Real multithreading" above for the full account.
- Only one spawnable shape (a tetrahedron), no general mesh import. The spawn API is real and general in how it's called (any position, any material, at runtime) — see "General spawn API" above — but every object is the same fixed shape, since there's no content pipeline yet to turn an arbitrary mesh into a tetrahedral one.
- No content pipeline for
.FEMmeshes without Houdini. FEMFX's own.FEMauthoring path requires a Houdini plugin; making this usable without Houdini (most likely via TetGen, tetrahedralizing an ordinary triangle mesh) is unstarted. Also noassimpor any other model-format loader anywhere in this repo — confirmed by checking, not assumed — so "import an arbitrary 3D model, assign it a material, watch it deform" needs both a model loader and tetrahedralization before it's possible, not just one of them. - No real stress-test demo. The particle system already proves
20,000 GPU-simulated particles; physics has only ever been tested up
to 8 objects (the current
kMaxObjectscap), and the single-threaded task system makes "does this scale" a genuinely open question, not just a buffer-sizing one. - This is a vendored copy of a patched fork, not a real fork.
Ideally these ~30+ fixes live in an actual git fork hosted somewhere
reachable, fetched via
FetchContentlike every other dependency in this engine — vendoring the source directly intoexternal/FEMFX/is the practical choice for now, not the intended long-term shape.
GPU profiler (VulkanProfiler) integration¶
VulkanProfiler
(VK_LAYER_PROFILER_unified) is integrated, and verified actually
running against this engine — not just researched. What it is, in
brief: a Vulkan layer, not a library this repo links against —
architecturally the same as the validation layer already conditionally
enabled in VulkanDevice. Because it's a transparent interception
layer, enabling it profiles every single Vulkan command this engine
issues the moment it's active — that's what "complete integration"
means for a tool shaped like this, not something achieved by manually
instrumenting call sites.
What's actually wired up¶
KKE_ENABLE_GPU_PROFILERCMake option (defaultOFF). When on,VulkanDevice::createInstance()checks whetherVK_LAYER_PROFILER_unifiedis actually installed (isInstanceLayerAvailable()— the same helper the validation-layer check now uses too, generalized rather than duplicated) and, if so, adds it to the enabled layer list and chains aVkLayerSettingsCreateInfoEXTrequestingsampling_mode = drawcall— the layer's finest-grained mode, matching "check everything up to the draw calls."- If the option is on but the layer isn't installed, this degrades exactly like a missing validation layer does: a clear warning logged through the same spdlog-based logger, nothing else changes, the engine runs normally. Verified both ways.
Verified by an actual build, install, and run — not assumed¶
This took real, hard-won verification, worth recording precisely:
- Cloned with
git clone --recursive(submodules: SPIRV-Tools, SPIRV-Cross, Vulkan-Headers, Intel'smetrics-discovery, its own vendored ImGui/ImPlot, and more), installed the stated Linux build deps (extra-cmake-modules,libdrm-dev,libxkbcommon-dev, X11/XCB dev packages), configured and built withcmake .. -DCMAKE_BUILD_TYPE=Release && make all. On a single-core sandbox, this took roughly 15 minutes — mostly SPIRV-Tools and a genuinely slow single-threaded LTO link step for Intel'smetrics_discoverylibrary (128 LTRANS units, serial on 1 core). Budget real time for this; it is not a quick dependency fetch. sudo cmake --install . --prefix /usr/local/placed the layer's.so, its JSON manifest (into/usr/local/share/vulkan/explicit_layer.d/, the standard path the Vulkan loader scans automatically), andVkProfilerEXT.h(the header declaringvkGetProfilerFrameDataEXTand friends — see "Not yet done" below).- First real run failed with
VK_ERROR_LAYER_NOT_PRESENT, even thoughvulkaninfoand our ownisInstanceLayerAvailable()check both correctly found the layer's manifest. Diagnosed rather than guessed:lddon the installed.soshowed no missing dependencies, ruling that out; the actual cause was that/usr/local/lib/x86_64-linux-gnu— where the.sowas installed — is a configured search path (/etc/ld.so.conf.d/x86_64-linux-gnu.conf) butldconfig's cache had never been refreshed after install, so a baredlopen("libVkLayer_profiler_layer.so")(what the manifest's relativelibrary_pathresolves to) failed. Runningldconfigfixed it immediately — worth remembering as a real, non-obvious install step, not assumingcmake --installalone is sufficient on every system. - With the cache refreshed, re-running
kke_demowithKKE_ENABLE_GPU_PROFILER=ONloggedGPU profiler layer 'VK_LAYER_PROFILER_unified' found and will be enabled, and its real overlay rendered on top of this engine's own frame — a genuine screenshot showedVkProfiler - llvmpipe (LLVM 20.1.2, 256 bits),Vulkan 1.2, liveGPU Time: 34.03 ms/CPU Time: 0.02 ms/Frame 94/25.1 fps, and working Performance/Memory/Inspector/ Statistics/Settings tabs — correctly reading this engine's actual device and actual per-frame timing, not placeholder UI.
Reproducing this yourself¶
# Linux build deps (see the layer's own README for the authoritative list)
sudo apt-get install -y extra-cmake-modules libdrm-dev libxkbcommon-dev \
libx11-dev libxext-dev libxcb1-dev libxcb-shape0-dev
git clone --recursive https://github.com/lstalmir/VulkanProfiler
cd VulkanProfiler && mkdir cmake_build && cd cmake_build
cmake .. -DCMAKE_BUILD_TYPE=Release && make all -j$(nproc)
sudo cmake --install . --prefix /usr/local/
sudo ldconfig # do not skip this — see step 3 above
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DKKE_ENABLE_GPU_PROFILER=ON
cmake --build build -j
./build/bin/kke_demo # the layer's overlay should appear on top of the engine's window
Not yet done — the honest remainder¶
vkGetProfilerFrameDataEXTwas attempted, and reproducibly crashes in this environment — a real, diagnosed finding, not an untried idea. The exact API is wired up correctly: function pointers loaded viavkGetDeviceProcAddr(VulkanDevice::loadGpuProfilerFunctions()), the real struct (VkProfilerDataEXT/VkProfilerRegionDataEXT) walked recursively to count actual leaf draw/dispatch/copy commands (VulkanDevice::queryGpuProfilerFrameSummary()), wired intoStatsModuleto display and log. Calling it segfaults — confirmed viagdbbacktrace to be inside the layer's own compiled code (vkGetProfilerFrameDataEXTitself), not this engine's code. Ruled out before concluding that: it isn't a "too early" timing issue (still crashes on the first call after 3+ real frames have already presented, confirmed with an explicit frame counter); it isn't the layer's background-threading option (VKPROF_enable_threading=falsemade no difference); it isn't thedrawcallsampling mode specifically (VKPROF_sampling_mode=commandbuffer, the coarsest mode, crashed identically). The layer's own overlay reads equivalent data correctly (see the verified screenshot above), which is what makes this specifically about calling the function from application code in this environment (lavapipe software rendering + this build of the layer), not evidence the layer itself is broken. Root-causing further needs the layer's own debug symbols or source stepping — real work, disproportionate to guess-and-check further. The code is written and disabled behind#ifdef KKE_QUERY_GPU_PROFILER_DATAinStatsModule.cpprather than deleted, so re-attempting this (on a different Vulkan implementation, a newer layer version, or with real hardware instead of a software rasterizer) is a one-line change, not a rewrite.- No headless/CI story yet. The layer's
output = tracemode (serializing to a JSON Event Trace Format file instead of an overlay) is the obvious fit for the headless Xvfb verification this project has used throughout, and for a future CI step — not wired up. - The layer itself is not part of this repo and can't sensibly be —
it's a large, platform-specific, system-installed artifact (like the
validation layer), not something
FetchContentshould vendor. Anyone building this engine withKKE_ENABLE_GPU_PROFILER=ONneeds to build and install it separately, following the steps above.
Debugging: pause/step and per-module fault isolation¶
Two related, verified features, both built around Application's frame
loop rather than bolted on separately.
Pause/step (Application::isPaused()/setPaused()/stepOneFrame(),
UI via DebugControlModule's "Debug Control" panel): freezes
fixedUpdate/update/compute — including GPU compute-driven
simulation like ParticleModule — while rendering keeps presenting
every frame regardless. The point is giving an external tool (RenderDoc,
a Vulkan profiling layer, or just your own eyes) a frame that holds
completely still instead of one that's still animating out from under
you. "Step one frame" advances exactly one fixed tick + one update()
call using the fixed tick length as a synthetic dt (not real
wall-clock time, which would be meaningless while paused), then
re-freezes. Verified, not assumed: two screenshots two seconds apart
while paused came back pixel-identical (cube, chaotic particle
positions, even the FPS counter — everything gated by the same pause
flag); each "Step" click produced a small, bounded advance rather than a
jump, confirmed by comparing consecutive screenshots.
Per-module fault isolation (Application::safeInvoke(), every
lifecycle call — init, fixedUpdate, update, onEvent, compute,
render, renderUi, shutdown — for every module goes through it):
a module that throws is logged with its name, which stage it was in,
and the actual exception message (via the same spdlog logger every
other module uses, at error level), recorded, and never called
again for the rest of the session — not even shutdown(). That
last part is deliberate: a module that has already misbehaved once
isn't a module whose cleanup code should be trusted either.
DebugControlModule shows every recorded failure in a bright red
"Emergency Log" ImGui window that only appears once something has
actually gone wrong. Verified with a real throw, not a hypothetical:
added a temporary module that threw std::runtime_error on its 30th
update() call, confirmed the process stayed alive, the exact expected
log line appeared —
[ThrowTest][KKE Engine Demo][error]: disabled for the rest of this
session after throwing during update(): deliberate test failure to
verify fault isolation
— and the Emergency Log window rendered with that same information while the cube kept spinning, particles kept simulating, and every other panel kept working, then removed the test module afterward.
The honest limit of this, stated plainly rather than glossed over:
if a module's init() throws partway through creating GPU resources,
that module's own destructor (still called normally later via
unique_ptr, since C++ object lifetime isn't something safeInvoke
can intercept) inherits whatever half-built state was left behind.
Every module in this engine builds GPU resources through RAII wrappers
(Buffer, Pipeline, etc.) specifically so a partial init() still
leaves safely-destructible state — but a module that doesn't follow
that pattern could still misbehave on destruction. Fault isolation
reduces this risk; it can't eliminate it for code this engine doesn't
control, and it was never going to — that's not a gap unique to this
implementation, it's a fundamental limit of exception-based isolation
in a language without memory/process sandboxing.
Clear errors for scripters, not just C++ exceptions¶
Fault isolation (above) answers "does one bad module crash everything"
— it doesn't answer "does the person who wrote the bad code understand
what's wrong." A raw C++ exception message like basic_string::at: __n
(which is 5) >= this->size() (which is 3) is precise and completely
useless to someone who wrote a script and has never heard of
basic_string::at. That's what kke::EngineError
(engine/include/kke/EngineError.h) exists to fix.
Any module — and, once it exists, the Lua scripting layer — can throw
EngineError (or the KKE_SCRIPT_ERROR(friendly, technical) /
KKE_ENGINE_ERROR(friendly, technical) convenience macros, which also
splice in __FILE__/__LINE__) instead of a bare std::runtime_error.
It carries three things a bare exception can't: a plain-language
message, which of engine-code/script-code is likely at fault
(kke::ErrorSource), and a file/line when known. what() still
returns the technical text — EngineError is a real std::exception,
so any code that doesn't know it's special (a generic catch
(std::exception&), plain logging) keeps working exactly as before.
Application::safeInvoke() catches EngineError specifically (before
the generic std::exception fallback) and records both messages,
the source, and the location into BrokenModuleInfo.
DebugControlModule's Emergency Log shows the friendly message as
the headline, a colored source badge (SCRIPT ERROR / ENGINE
ERROR / UNKNOWN SOURCE), the file:line when available, and the
technical message tucked behind a collapsed "Technical details" —
visible for anyone who wants it, not forced on someone who doesn't.
A plain std::runtime_error still works everywhere; it just can't
offer any of the richer fields, and is honestly labeled UNKNOWN
SOURCE rather than guessed at.
Verified with both paths side by side, not just one:
[ScriptErrorTest][KKE Engine Demo][error]: disabled for the rest of this
session after throwing during update() at .../ThrowTestModule.h:14 —
The recipe needs more sugar than you gave it — check line 14.
[PlainThrowTest][KKE Engine Demo][error]: disabled for the rest of this
session after throwing during update(): a plain std::exception with no
friendly-message split
— and on screen, the Emergency Log showed the first as an orange
[SCRIPT ERROR] badge with the real file/line and the friendly
message front and center (technical detail collapsed), and the second
as a gray [UNKNOWN SOURCE] badge with just its one available message
— no fabricated location, no invented friendly text standing in for
something that was never provided.
What this doesn't solve yet: there's no Lua scripting layer for
this to actually serve its intended audience with today — a C++
module author can use EngineError right now, but the "someone who
isn't a C++ programmer" case this was built for needs the Lua binding
layer (see Roadmap) to translate its errors (a bad script line, a
missing value) into EngineError calls. This is the plumbing that
layer will use, built and verified ahead of it existing, not a
replacement for it.
Performance / profiling tools¶
StatsModule is the "how performant is this actually" panel: FPS, CPU
frame time, and GPU frame time (measured via vkCmdWriteTimestamp
bracketing the render pass in Renderer), each with a 240-frame rolling
graph so spikes are visible, not just an instantaneous number that flickers
past. This is the base to build real profiling on top of — natural next
additions, all following the same "keep a small history buffer, plot it"
shape:
- Per-module CPU time (wrap each
update()/render()call in a timer) - GPU memory usage (VMA exposes budget/usage stats via
vmaGetHeapBudgets) - Draw call / dispatch counts per frame
Known simplifications (intentional, still worth fixing)¶
This section is being retired in favor of ROADMAP.md (current
capability state, by system) and BUGS.md (specific defects, symptom →
root cause → fix, cross-referenceable by ID) — both maintained as
living, structured files rather than README prose. The entries below
are kept only as a pointer, not duplicated and re-maintained here,
specifically because letting the same information live in two places
is exactly how this section itself went stale in the past — it claimed
RmlUi's LoadTexture was still a stub, and separately claimed resize
handling was fully verified, in both cases after later work in the
very same project had already changed that (see BUGS.md BUG-004 and
BUG-018 for the real, current, corrected state of each). If you're
looking for "does X work yet," start in ROADMAP.md; if you're
debugging something and want to know if it's a known issue, start in
BUGS.md.
A few things not yet tracked as a specific system in ROADMAP.md
because they're small, standalone facts rather than a whole subsystem:
shader paths are relative to the working directory, not the executable
path (fine for ./build/bin/kke_demo run from within build/bin/, will
need SDL_GetBasePath() before shipping elsewhere); mouse orbit doesn't
use locked/relative cursor mode (dragging past the window edge stalls
the drag rather than wrapping, a deliberate simplicity trade-off against
fighting ImGui's own mouse handling); depth format isn't probed for
support (VK_FORMAT_D32_SFLOAT is assumed available, true on
effectively every Vulkan-conformant device including lavapipe, rather
than checked via vkGetPhysicalDeviceFormatProperties with a fallback);
20,000 particles is an arbitrary default, not a measured performance
ceiling.
Roadmap¶
For current, structured state, use ROADMAP.md (capability status by
system) and BUGS.md (specific defects, cross-referenceable by ID)
instead of reading this section top-to-bottom. This section remains
as the fuller narrative — the reasoning, the false starts, the "tried X
first, it was wrong, here's why" — kept in chronological order because
that reasoning has real value when you're about to touch the same area
again. But it is not the place to answer "does X work yet" or "has this
bug been seen before" — those two files are, and they're the ones this
project now commits to keeping current every session (see each file's
own intro for the discipline expected).
Immediate next slices (each independently buildable/runnable)¶
-
Physics performance, slice 1 — done, measured on a min-spec emulation (1 core). The short version: physics was mostly doing work it didn't need to. FEMFX ran unoptimized in Debug builds; its sleep system was switched off and, once switched on, the ground rigid body woke every resting piece again the next step; every hidden interior triangle was rebuilt and uploaded every frame; and the fixed-step loop ran up to 8 catch-up ticks per frame, turning one slow tick into eight. Along the way: fractured objects were mostly invisible (all pieces were uploaded to the same spot in one buffer), scene capacities silently capped fracture at ~64 pieces, and every demo crashed on exit (an RmlUi listener use-after-free). Result over the scripted benchmark on one core: 0.6 → 11.0 FPS average with six times as many pieces, and a settled 475-piece pile costs ~0.2 ms per physics step. Numbers and next steps in
PERFORMANCE_NOTES.md("Status"), evidence per fix inBUGS.mdBUG-025..BUG-030, and what still needs real hardware inHARDWARE_TESTS.md. -
~~Material toughness ordering was backwards — Rubber shattered while Iron stayed intact under the same impact~~ Fixed — root- caused properly against real FEMFX source and real measured data this time, not another round of guessing from a formula.
- Read FEMFX's own source to understand the real physics
(
FEMFXUpdateTetState.cpp): stress is computed asstiffness_matrix * displacement, not from stiffness alone — so a stiffer material doesn't automatically produce proportionally higher stress; it depends on how much the object actually deforms under a given impact, which itself depends on stiffness, mass, and the full contact dynamics. The earlier "scale the threshold linearly with stiffness" retuning was a reasonable-sounding formula that turned out to be the wrong mental model. - Measured the real thing directly instead of theorizing further: added a temporary diagnostic straight into FEMFX's own fracture- check code (there's no public API for this internal value), and tested all five materials under the identical standard impact. Real, measured stress ranges, not estimates: Rubber ~1,200–8,450; Wood ~1,483–22,485; Stone ~2,386–52,794; Glass ~4,719–36,640; Iron ~6,690–314,820. Confirms stress does trend upward with stiffness, but the ranges overlap enough that no single formula could have placed every material's threshold correctly relative to its own real range — exactly why the previous attempt got Rubber and Iron backwards.
- Retuned every preset relative to its own measured range, not a
shared formula: Glass and Stone sit near the low end of their own
range (reliably fracture), Wood sits mid-range, Iron sits high in
its own range (mostly dents instead of shattering), and Rubber
sits above its own observed maximum entirely (never fractures
under this demo's real impacts). Applied consistently everywhere
the old scale had leaked in — the standalone scene buttons (Glass
Sheet, Brick, Car Crash's wall and car body, Lava Melt's
softened-metal block, Rubber Ball) all had their own separately
hardcoded materials at the old, wrong scale too, not just
MaterialGridModule's presets. - Verified with real, repeated testing under the identical impact, not assumed correct from the new numbers alone: Glass shattered into 13 pieces; Iron broke into only 9–11 (tougher, fewer pieces, the correct relative direction this time); Rubber, after one more real round of testing and raising its own margin further, produced zero fracture log lines at all — genuinely bouncing intact under the same impact that used to shatter it.
- The temporary diagnostic added to FEMFX's own vendored source was fully removed afterward — confirmed via a real search across the whole tree, not assumed.
- Full comprehensive verification: both
KKE_ENABLE_FEMFXon and off configurations rebuilt clean from scratch — zero errors, zero warnings, all 40 tests passing, correct binary set, in each — plus every demo swept individually with real Vulkan validation layers active in both configurations: zero validation errors, zero crashes, zero assertions throughout. - Scene: Lava Melt — the fifth physics scene, an honest, clearly-
labeled approximation, not real melting. Stated plainly rather
than implied otherwise: FEMFX has no phase-change or topology-loss
simulation at all, so there's no way to make a solid object actually
liquefy or lose volume. What this scene does instead uses only real,
already-proven mechanics: a target block with a genuinely low
plastic yield threshold (softened metal, not fresh iron), and three
real, heavy "lava chunk" objects dropped on top of it. Real physics
does the rest — the chunks' own sustained weight keeps the block's
internal stress above its yield threshold long after the initial
impact, so it keeps slowly, permanently sagging under that ongoing
load, the same real
plasticCreep-driven mechanic "Spawn plastic cube" demonstrates in isolation, just sustained by continued weight instead of a single impact. - Added a real, generated sixth texture (lava — bright red-to-yellow
gradient with irregular hot spots, generated the same procedural
way as the other five) to
PhysicsModule's own material texture library. - Verified with real, measured numbers, not just a screenshot: the target block's own vertex-distance diagnostic (see "Spawn plastic cube" for how this measurement works) dropped from 1.0 to 0.5 — real, dramatic, 50% compression — confirmed visually too, with the lava chunks' own distinct red-orange texture clearly visible in a real screenshot next to the sagged block.
- Full comprehensive verification: both
KKE_ENABLE_FEMFXon and off configurations rebuilt clean from scratch — zero errors, zero warnings, all 40 tests passing, correct binary set, in each — plus every demo swept individually with real Vulkan validation layers active in both configurations: zero validation errors, zero crashes, zero assertions throughout. - All five originally-requested physics scenes (Glass Sheet, Brick, Rubber Ball, Car Crash, Lava Melt) are now real and working.
- Real panel dragging by title bar — a genuine, reported gap
closed: panels couldn't be repositioned by the user at all before
this. A general mechanism, not per-panel special-casing: any element
with class
draggable-handlestarts a drag on mousedown, and dragging moves its nearest ancestor with a real, explicitleftproperty set (seefindDraggablePanelAncestor's own comment) — the samemousedown/mousemove/mouseuppattern already proven for slider dragging, applied to whole panels instead of a single control's value. Added thedraggable-handleclass to the title element of all three panel documents (LightingControlsModule,MaterialGridModule,MarketplaceUiModule). - Two real, separate bugs found and fixed during verification, not
glossed over:
- A genuine
font-familyinheritance gap — confirmed via a real RmlUi warning (No font face defined... On element h1.draggable-handle) — meant the "Lighting Controls" and "Material Grid" panel titles weren't rendering at all, which also explains the mysteriously-missing title noticed during the earlier window-resize testing. Fixed with an explicitfont-familyon both title rules rather than relying on inheritance; verified with a real screenshot showing both titles now visible. MarketplaceUiModule's own internal structure never received thepointer-events: autotreatmentLightingControlsModule/MaterialGridModulegot when the earlier click-interception bug was fixed — confirmed directly via a diagnostic showing clicks landing on#rootinstead of the actual title element. Fixed by addingpointer-events: autoto both the panel's outer div and the title itself.- A real bug in the drag code's own ancestor-search logic: it
started searching from the clicked handle itself, and
GetProperty("left")turned out to return non-null even for that handle (apparently an explicit "auto" rather than a true null) — so the search matched the title text element on its first check and never walked any further, meaning a drag would have moved just the title, not the panel. Found via a direct diagnostic log showingpanel found, tag='p'where adivwas expected, fixed by starting the ancestor search from the handle's parent instead of the handle itself.
- A genuine
- Verified with a real before/after screenshot, not just compiling cleanly: dragged the Marketplace panel by its title and confirmed the entire panel — including every game card inside it — moved together as one unit, following the mouse.
- Full comprehensive verification, after removing every temporary
diagnostic added during the investigation: both
KKE_ENABLE_FEMFXon and off configurations rebuilt clean from scratch — zero errors, zero warnings, all 40 tests passing, correct binary set, in each — plus every demo swept individually with real Vulkan validation layers active in both configurations: zero validation errors, zero crashes, zero assertions throughout. - Four real, playable physics scenes — real, distinct shapes and
scene-appropriate materials, not the same cube recolored four times.
PhysicsModulegained a generalbuildGridBox(cellsX, cellsY, cellsZ, sizeX, sizeY, sizeZ)generator (the same proven 6-tet diagonal decomposition already used for the fracturable cube, generalized to any per-axis cell count and physical size), and the existing "Spawn fracturable cube" button was refactored to use it too instead of its own separate inline copy. - Scene: Glass Sheet — thin and wide (72 tets), the real,
empirically-verified glass fracture threshold already proven in
MaterialGridModule's own preset. Verified via the sameFmGetNumTetMeshes()logging this project has used throughout: shattered into 58 pieces on impact, confirmed visually in a real screenshot as shards genuinely scattered across the ground, not a couple of large chunks. - Scene: Brick — real 2:1:1 brick proportions, stone material. 26 pieces, confirmed the same way.
- Scene: Rubber Ball — an honest approximation, stated plainly rather than implied otherwise: a small box of real rubber, not a true tetrahedralized sphere (which would need real mesh-import machinery this project doesn't have yet — see "Content pipeline: CGAL tetrahedralization"). Hit a real snag during verification: the first threshold tried still fractured on impact, the same "actual stress magnitudes are hard to predict from the material parameters alone" problem already flagged as an open question for material tuning generally. Fixed pragmatically here by raising the threshold until a real test run confirmed no fracture log fired at all — the ball stays intact and bounces under real elastic physics.
- Scene: Car Crash — two real, distinct objects for the first time in one scene: a plastic "car" (real permanent denting, the same mechanic "Spawn plastic cube" demonstrates in isolation) driven at real velocity into a fracturable "wall" (the same mechanic the fracture scenes use). Verified with real, measured numbers, not just a screenshot: the wall shattered into 57 pieces, and the car's own vertex-distance diagnostic (see "Spawn plastic cube" for how this measurement works) dropped from 1.0 to 0.33 — real, dramatic, measured crumpling — confirmed together in one real screenshot showing wall debris genuinely scattered across the scene from the impact.
- What's honestly not done: a fifth scene (lava melting a cube) wasn't attempted — FEMFX has no true melting/phase-change physics at all, so it would need a creative, clearly-labeled approximation (plasticity plus a particle effect) rather than real melting, and that work hasn't started.
- ~~UI panels don't scale with window size — go off-screen at
smaller resolutions~~ Fixed for panel position — confirmed
directly, not assumed: resized the window from the demo's own
1600x900 down to 900x600 and screenshotted the real result before
touching any code.
MaterialGridModule's panel (positioned at a fixedtop: 640px) was completely off-screen;MarketplaceUiModule(fixedleft: 820px) was severely cut off at the right edge. - Fixed: converted
left/topfrom fixed pixels to percentages of the 1600x900 reference design size, at the one point each module actually applies its position (SetPropertyforLightingControlsModule/MaterialGridModule, the inline style string forMarketplaceUiModule). RmlUi resolves percentage left/top against the containing block's own current size, so this now genuinely tracks whatever the window actually is, not a fixed 1600x900 assumption. Public constructor APIs (left/topas pixel arguments) deliberately unchanged, so no call site needed to change — only the internal application of those values did. - Verified with a real before/after screenshot at the same resized
window:
MaterialGridModule's panel went from fully off-screen to fully visible;MarketplaceUiModulewent from severely cut off to proportionally repositioned. - What's honestly still incomplete: panel widths are still
fixed pixels (
width: 740pxetc.), so a panel can still overflow a narrow window even though its position now scales correctly — real, separate follow-up work, not done here. The Lighting Controls panel's own title text also appeared to go missing in the same resized screenshot, a smaller, distinct issue not yet investigated. - Full comprehensive verification: both
KKE_ENABLE_FEMFXon and off configurations rebuilt clean from scratch — zero errors, zero warnings, all 40 tests passing, correct binary set, in each — plus every demo swept individually with real Vulkan validation layers active in both configurations: zero validation errors, zero crashes, zero assertions throughout. - ~~RmlUi felt broken — sliders wouldn't drag, panels couldn't be moved~~ Fixed — a real, likely-unifying root cause, found through a genuinely thorough diagnostic chain, not assumed.
- A real, concrete gap found first: the slider "drag" support only ever handled the initial mousedown click — nothing tracked that a drag was in progress, so holding and moving the mouse afterward did nothing. Fixed by extracting the value computation into a shared helper and wiring real drag state through mousedown → mousemove → mouseup.
- But verifying that fix uncovered something much bigger: a
direct diagnostic log showed clicks anywhere near the Lighting
Controls panel — even well inside its own reported bounds —
resolving to
body, RmlUi's document root, never descending into the actual panel or its children. Ruled out, one at a time, with a real check each time, not a guess: a coordinate/DPI mismatch (SDL's logical size and the renderer's pixel extent matched exactly); window positioning (the window sits at (0,0)); event routing (every SDL event reaches every module'sonEvent()unconditionally, no ImGui-capture filtering exists); and timing (even a full 1-second delay between mouse-move and click didn't change the result). - The real cause: every RmlUi document's
<body>covers the entire viewport for hit-testing by default, regardless of where its own visible content sits. With several of this engine's UI panels each living in their own document, shown simultaneously, the most-recently-added module's document (MaterialGridModule, added afterLightingControlsModule) sat on top in z-order and its own invisible, full-screen body silently absorbed clicks across the entire screen — including areas over completely different panels' visible content.MarketplaceUiModulehad already independently found and fixed this exact bug for itself in an earlier session (a real, detailed comment already documented it) — the gap was that the same fix was never applied to the panels added afterward. - Fixed:
pointer-events: noneon every document's<body>(as an inline style on the<body>tag itself specifically — a real, checked detail: a<style>block rule alone was tried first and did not work, only the inline attribute did, matching exactly howMarketplaceUiModule's already-proven version does it), with each real panel div explicitly opting back in viapointer-events: auto. - Verified with a real before/after screenshot, not just a log
line: the same click that used to resolve to
bodynow resolves toinput.range, the actual slider — and a real, continuous drag (mousedown, several mousemove steps, mouseup) visibly moved the slider thumb from one end of the track to the other in an actual screenshot comparison. - Full comprehensive verification, after removing every temporary
diagnostic added during the investigation: both
KKE_ENABLE_FEMFXon and off configurations rebuilt clean from scratch — zero errors, zero warnings, all 40 tests passing, correct binary set, in each — plus every demo (kke_demo,physics_demo,rmlui_demo,imgui_demo) swept individually with real Vulkan validation layers active in both configurations: zero validation errors, zero crashes, zero assertions throughout. - What's still open, honestly: whether this same root cause also explains the reported "UI doesn't scale with screen size" and "discoloration behind 3D content" complaints hasn't been checked yet — panel dragging (moving a panel by its title bar) hasn't been implemented at all yet either. All real, separate next steps, not yet started.
- Soft shadows (3x3 PCF) — a real quality upgrade to the existing
shadow map, replacing the single hard-edged tap with a 9-sample
neighborhood average, using
textureSize(shadowMap, 0)to derive the correct texel offset at runtime rather than hardcoding the resolution separately in the shader. - Shadow casting generalized to
PhysicsModule— spawned physics objects now cast real shadows too, not justCubeModule's cube. The ground plane deliberately doesn't cast (it only needs to receive). Reuses each object's existing vertex/index buffers as-is rather than resynchronizing them for the shadow pass specifically — a real, deliberate simplicity trade-off (at most one frame of shadow-position lag, ~16ms, not visually meaningful at this project's motion speeds) over duplicating render()'s own position-readback logic a second time. - ~~A real shadow regression, found and fixed via a genuinely thorough diagnostic chain~~ — while verifying the two items above, shadows turned out to be completely broken (every fragment reading as fully lit, no shadow anywhere), a real regression from an earlier point in the project, not something introduced by this round's own changes. Root-caused properly rather than patched around:
- The actual bug:
shadow.vertstill declared only 3 vertex attributes (position/color/normal) from beforeVertexgrew a 4th field (uv) for the material-texture work done earlier this project. Since the shadow pipeline's vertex input state is always built fromVertex::attributeDescriptions()(now 4 attributes), there was a real mismatch between what the pipeline described and what the shader consumed. This didn't trip Vulkan validation (unused pipeline attributes aren't strictly invalid) but caused a silent failure on this project's own software rasterizer — every shadow-casting draw call produced no real depth output, leaving the shadow map permanently at its cleared value. - Found by elimination, each step a real measurement, not a
guess: ruled out the new
PhysicsModuleshadow code (disabled it, bug persisted identically); ruled out the PCF rewrite (the broken value was read before the PCF loop even executes, from code identical to the working single-tap version); verified the C++/GLSLLightingUBOstruct layout byte-for-byte with a standalone test program; verified the light direction and computed matrix were sane via a direct log; verifiedposLightSpace.wwas correctly 1.0; verified the light-space UV coordinates were genuinely varying correctly across the cube's surface (an earlier "looks uniform" read turned out to be simple visual misjudgment on a subtle gradient, corrected by sampling actual pixel values instead of eyeballing); verifiedtextureSize()returned a real, reasonable value; and finally sampled the shadow map's own stored depth directly, finding it exactly1.0(the clear value) everywhere — which pointed straight at the vertex shader not writing real geometry at all, and from there directly at the stale attribute declaration. - Fixed and reverified: added the missing
uvattribute declaration toshadow.vert(unused in the shader body, but now matching the pipeline's real vertex input state exactly). A real shadow is visible again in a fresh screenshot — a distinct dark shape on the ground plane, cube-shaped, cutting across the checkerboard texture pattern exactly where expected. - Full comprehensive verification, after fully removing every
temporary diagnostic added during the investigation: both
KKE_ENABLE_FEMFXon and off configurations rebuilt clean from scratch — zero errors, zero warnings, all 40 tests passing, correct binary set, in each — plus every demo (kke_demo,physics_demo,rmlui_demo,imgui_demo) swept individually with real Vulkan validation layers active in both configurations: zero validation errors, zero crashes, zero assertions throughout. - ~~Objects fall through the ground when many are spawned~~ Fixed
— a real, serious bug, reported directly from actually using the
demo (spawning many pieces), not caught by this project's own
earlier testing (which never spawned anywhere near the 64-object
cap at once). Root cause, found immediately by reading the scene
setup:
maxDistanceContacts,maxVolumeContacts,maxBroadPhasePairs, andmaxDeformationConstraintswere all still hardcoded at 64 — sized forkMaxObjects's original value from before it was raised to 64 earlier this project, never revisited when that cap changed. With genuinely dozens of objects on screen, each touching the ground plus potentially several piled-up neighbors, the real number of simultaneous contacts routinely exceeds a fixed 64 — and a contact FEMFX has no room to record is a contact that gets no real collision response, which looks exactly like falling through the floor. Fixed by scaling all of these to a real, generous multiple ofkMaxObjectsinstead of another fixed number that would just as quietly run out again next time that cap changes. Verified by actually doing what the report described: spawned tetrahedra rapidly up to 57 objects, all resting correctly on the ground plane, confirmed via a real screenshot — no falling through, at a scale roughly 9x larger than anything previously tested in this project. - ~~Fracture doesn't look like real breaking — a piece just disappears~~ Fixed — two real, compounding bugs, not one.
- The fracturable shape itself was too coarse to ever look like real breaking: the original "Spawn fracturable cube" used a single 6-tet cube — with that few internal boundaries, fracture could only ever separate it into at most a couple of pieces, far short of looking like glass shattering or rock breaking into chunks. Replaced with a real 2x2x2 grid of connected cube-cells (48 tets total, same proven 6-tet-per-cell decomposition, just tiled), giving fracture genuine room to separate into many distinct fragments.
- The button never actually used the selected material: despite
kke::MaterialGridModuleexisting specifically so a chosen material's ownfractureStressThresholdwould apply, "Spawn fracturable cube" ignoredm_selectedMaterialentirely and used a single hardcoded material regardless of what was selected — meaning choosing "Glass" vs. "Rubber" never actually changed anything about how the object broke. Fixed to use the real selected material. - A related bug found and fixed while retuning:
MaterialGridModule's five presets hadfractureStressThresholdvalues (3e5 to 5e8) carried over from AMD's own reference material examples — but this project's own earlier empirical fracture work had already found real simulated stress magnitudes here run orders of magnitude smaller than AMD's scale (a working threshold of 10.0, not 1e4-1e8). None of the five preset values were ever reachable by any real impact this demo produces, meaning every material silently behaved like the unbreakable default regardless of selection — retuned into the same real, working range. - Verified with real numbers, and an honest, unresolved finding
reported rather than hidden: spawned all five materials under
the identical impact and logged
FmGetNumTetMeshes()for each. Confirmed real, substantial fragmentation now works — Glass split into 13 pieces, visibly separated in a real screenshot, a dramatic, genuine improvement over the old single-piece-disappears behavior. Confirmed real material-dependent differentiation exists (not every material breaks identically) — but the specific toughness ordering found empirically (Rubber breaking apart while Iron initially stayed intact under the same impact) doesn't yet match intuitive real-world expectations (glass most brittle, iron toughest). Likely because peak internal stress under impact depends on a material's full stiffness/density response, not simply its fracture threshold in isolation — a real, deeper tuning question left open honestly rather than papered over with an unverified guess. - ~~Physics tetrahedra looked "hollow"~~ Fixed — a genuine, real bug, reported directly by actually using the demo, not found through internal testing. The visual symptom (a smooth, iridescent-looking gradient sweeping across what should have been sharp, distinct flat faces) was diagnosed exactly the way this whole session has diagnosed everything else: a temporary shader outputting the raw world-space normal directly as color, bypassing all lighting, made the real cause unambiguous rather than guessed at.
- The real cause:
PhysicsModule's render bridge shared vertices between adjacent triangular faces and averaged their normals together — the exact same technique correctly used for genuinely high-poly, curved-looking meshes, but wrong for a 4-faced tetrahedron (or any object with only a handful of exterior faces), where averaging blends each face's shading into its neighbors instead of keeping them sharply distinct. - Fixed with a real restructure: switched from shared, indexed
vertices to
numTets * 12unique per-face vertices (4 faces × 3 corners each, none shared with any neighboring face), each getting its own single, correctly-computed face normal — true flat shading, the standard technique for exactly this situation. Touched both the spawn-time buffer sizing (now sized for unique per-face vertices, not FEMFX's own smaller shared-vertex count) and both render paths (fracturable and non-fracturable). - Verified with a real, unambiguous before/after screenshot: the same diagnostic shader that found the bug, re-run after the fix, showed two sharply distinct, differently-shaded flat faces meeting at a crisp edge — a completely different, correct result from the smooth blob before.
- Checked
CubeModuletoo, since it showed the same symptom by report — confirmed it was already correctly flat-shaded (it uses real, explicit per-face normals, not averaged ones), so no fix was needed there. - Real per-face UV mapping for physics-spawned tetrahedra — the
gap flagged as "still ahead" the moment the material texture library
was built. Made possible directly by the flat-shading fix above:
since every face now already has its own unique, unshared vertices,
giving each one real UV coordinates (a standard
(0,0), (1,0), (0,1)per-triangle mapping, not theVertexstruct's own(0,0)default) was a natural, small extension rather than a separate rewrite. - A real, honestly-investigated verification, not assumed correct: the first check (a raw-texture diagnostic shader, hunting for a spawned "Wood" object) found nothing at all in the frame — genuinely concerning, since it looked exactly like a regression. Rather than conclude that from one ambiguous result, isolated it properly: reverted to normal lit rendering first, confirmed objects were visible again (ruling out a real rendering regression), then did a full-frame programmatic pixel scan for the wood color specifically and found it immediately. The earlier "nothing visible" was the default-textured objects' white blending into the ground's own white in that specific raw-texture diagnostic mode — a diagnostic-visibility artifact, not a bug.
- An honest, real limitation found and stated plainly, not oversold: with real UV mapping now in place, sampling is confirmed correct (distinct per-material colors, correct flat shading preserved) — but the wood grain pattern's fine detail isn't clearly visible at a physics tetrahedron's actual on-screen size, most likely ordinary texture minification (many texture texels compressing into very few screen pixels on a small, distant object) rather than a bug. Worth revisiting with a texture pattern tuned for small-scale legibility (larger, higher-contrast bands) if this matters more later — not chased further this round.
- Full comprehensive verification: both
KKE_ENABLE_FEMFXon and off configurations rebuilt clean from scratch — zero errors, zero warnings, all 40 tests passing, correct binary set, in each — plus every demo (kke_demo,physics_demo,rmlui_demo,imgui_demo) swept individually with real Vulkan validation layers active in both configurations: zero validation errors, zero crashes, zero assertions throughout. - Real material texture library — five distinct, generated textures
in
PhysicsModule, tied tokke::MaterialGridModule's existing presets, not a one-off added toCubeModulealone. Added a realtextureIdfield toMaterial(defaults to -1, meaning "use the shared default white texture," matching the same convention every other optional field in this codebase follows), five procedurally generated textures (Wood: brown grain bands, Stone: gray speckle noise, Iron: brushed-metal streaks, Rubber: dark flat, Glass: light blue-tinted, each using a small deterministic hash for noise rather than an external RNG dependency), and a real per-object descriptor set rebind inrender()— each spawned object now binds its own material's real texture, not a single shared one. - A real, honestly-documented limitation, found and understood,
not hidden:
PhysicsModule's dynamically-generated tetrahedra don't have real per-vertex UV coordinates (they default to(0,0), same asVertex's own default), so instead of a visible tiled pattern likeCubeModule's checkerboard, each object samples its material's texture at one fixed point — showing as a flat, real, per-material color tint rather than a visible pattern. This is a genuine scope boundary worth stating plainly, not a bug: giving physics-spawned tetrahedra real per-face UV mapping is real, separate future work. - A verification journey worth recording honestly: the first attempt (screenshot + diffing against a baseline) found zero visible change in the 3D viewport after spawning a material — concerning, and could easily have been mistaken for a real bug. Rather than conclude that from an ambiguous screenshot, switched to a more reliable method: a real, temporary diagnostic log inside the actual bind call itself (independent of camera framing, object fall time, or screenshot timing) confirmed directly that Wood (textureId=0), Iron (textureId=2), and Glass (textureId=4) each correctly resolve to their own distinct, valid descriptor set. The earlier "no visible change" was a real camera-framing issue with the screenshot method, not a bug in the feature — confirmed, not assumed, before trusting either conclusion. Both temporary diagnostics (a shader bypass outputting the raw texture sample, and the bind-call log) were reverted afterward.
- Full comprehensive verification: both
KKE_ENABLE_FEMFXon and off configurations rebuilt clean from scratch — zero errors, zero warnings, all 40 tests passing, correct binary set, in each — plus every demo (kke_demo,physics_demo,rmlui_demo,imgui_demo) swept individually with real Vulkan validation layers active in both configurations: zero validation errors, zero crashes, zero assertions throughout. - Real material textures — the core
Vertexformat gained genuine UV coordinates, andCubeModulenow samples a real, generated albedo texture. A real, reusablekke::Textureclass, built specifically as its own independent code rather than reusingRmlVulkanRenderInterface's own texture-loading internals — sharing it risked destabilizing an already-verified, working RmlUi code path for no real benefit, given the two use cases need genuinely different sampler settings anyway (this class uses LINEAR+REPEAT, correct for a texture tiled across a mesh's UVs; RmlUi's own uses CLAMP_TO_BORDER, correct for a UI image that should never tile). - A real, generated procedural checkerboard, not a placeholder image — deliberately chosen over loading a file: a checkerboard immediately and visibly proves UV mapping is correct (a wrong or degenerate UV shows up instantly as a distorted or missing checker pattern), and needs no external asset at all, matching the same self-contained approach the RmlUi image-loading work took with its own generated test PNG.
- A real architectural extension, not a hack:
Vertexgained a realuvfield, appended (not inserted) so every existingVertex{...}initializer acrossPhysicsModuleandDestructionModulestayed valid unmodified.Mesh::createCube()got real, correct per-face UV coordinates.cube.vert/cube.fraggained a real texture sampler (set 2), designed so a plain white texture is an exact no-op (white × vertexColor = vertexColor) — meaningPhysicsModuleandDestructionModuleneeded their pipelines/render calls updated to bind this new mandatory set (the same real consequence of sharingcube.fragthat shadow mapping and PBR materials each already required), but every object drawn through them keeps rendering exactly as before, completely unaffected visually, unless it's given a real texture of its own. - Verified with an immediate, unambiguous screenshot — no pixel-
sampling detective work needed this time (unlike the PBR
verification's own real methodology bug, see above): a real,
visible checkerboard pattern is directly visible on the cube's
faces, while
PhysicsModule's ground plane (using the shared default white texture) stays a flat, solid color, exactly as intended — confirmed with a zoomed screenshot of each. - Full comprehensive verification: both
KKE_ENABLE_FEMFXon and off configurations rebuilt clean from scratch — zero errors, zero warnings, all 40 tests passing, correct binary set, in each — plus every demo (kke_demo,physics_demo,rmlui_demo,imgui_demo) swept individually with real Vulkan validation layers active in both configurations: zero validation errors, zero crashes, zero assertions throughout. - What's deliberately still out of scope: normal maps,
roughness/metallic textures (still one number per object, not a
per-pixel sample), texture loading from real files for objects
other than
CubeModule, and real image-based ambient lighting (stillambientColor * albedo, a flat stand-in, not a captured/ convolved environment map). - Real PBR materials — Cook-Torrance BRDF, replacing Blinn-Phong entirely, not layered on top of it. GGX normal distribution, Smith geometry function, Fresnel-Schlick, proper energy conservation between diffuse and specular, metals correctly reflecting their own albedo color as base reflectivity rather than a flat gray, and Reinhard tone mapping (real PBR specular can legitimately exceed 1.0 per channel with strong lights/low roughness — a correct result, not a bug, that needs compressing back into displayable range).
- A real architectural problem, solved properly, not worked
around: the existing push constants were already at 128 bytes —
Vulkan's guaranteed-minimum limit — with no room for new fields.
Fixed by removing the redundant
mvpmatrix entirely (always justproj*view*model, recomputed identically per object every frame for no reason) and moving the sharedproj*viewintoLightingUBOonce per frame instead — freeing real room for genuine per-objectmetallic/roughness, and removing real duplicated per-object matrix work as a side effect. - Genuinely integrated, not bolted on:
kke::Materialgained realmetallic/roughnessfields living alongside its existing physical properties (the same struct FEMFX already reads density/ stiffness from), andMaterialGridModule's five presets now double as real visual presets with zero extra plumbing — Iron is actually metallic, Glass is actually smooth, Rubber is actually rough, because they already carry a realMaterialan object's renderer now reads visual properties from too. - Added real, live "Metallic"/"Roughness" sliders to
CubeModule's own UI panel specifically so this is genuinely demonstrable, not just trust-me-it-compiles — confirmed interactively that both sliders move independently and hold their set values (0.100 → 1.000 metallic, 0.400 → 0.000 roughness, each confirmed via a zoomed screenshot of the actual displayed number, not assumed from the click alone). - A genuine methodology bug, found, understood, and fixed — not
just retried until something worked. The first verification
attempt froze the cube (unchecking "Spinning") and compared pixel
colors at fixed screen coordinates across metallic values —
byte-identical, no difference at all, which looked exactly like a
real bug in the implementation. Rather than trust that surface
reading, checked it directly: added a temporary diagnostic shader
(
outColor = vec4(metallic, roughness, 0, 1), bypassing all BRDF math entirely) to see definitively whether these values were reaching the shader at all. That immediately revealed the actual problem — this scene has bothCubeModule's cube andPhysicsModule's own ground plane sharingcube.frag, and the original comparison had been sampling the ground plane's fixed, unrelatedmetallic=0.0/roughness=0.9, not the cube at all. Gave the cube's own default an extreme, unmistakable value (pure red in the diagnostic output) specifically to locate its true screen region unambiguously, confirmed it was a distinct, separate colored shape from the ground plane, then reverted both the diagnostic shader and the temporary extreme defaults back to normal. - Redone correctly, with real, quantitative before/after
numbers: froze the cube at a fixed angle, sampled the correct
region this time, and compared actual pixel values.
Metallic 0.1 → 1.0 at identical frozen coordinates:
(110,53,53) → (102,48,48), a real, consistent shift across every sample point, not noise. Roughness 0.4 → 0.0 (metallic held at 1.0):(102,48,48) → (101,48,48), smaller but still real and consistent — physically reasonable, since this particular face isn't catching a strong direct specular highlight at this specific viewing angle, so roughness (which only affects the specular lobe) has less to act on here than metallic (which also reduces the diffuse term directly) does. - Full comprehensive verification: both
KKE_ENABLE_FEMFXon and off configurations rebuilt clean from scratch — zero errors, zero warnings, all 40 tests passing, correct binary set, in each — plus every demo (kke_demo,physics_demo,rmlui_demo,imgui_demo) swept individually with real Vulkan validation layers active in both configurations: zero validation errors, zero crashes, zero assertions throughout. - What's deliberately still out of scope: real image-based
ambient lighting (the ambient term is still
ambientColor * albedo, a flat stand-in, not a captured/convolved environment map — see "What's still ahead for lighting" below), normal maps, and albedo/roughness/metallic textures (every value here is still a single per-object number, not a per-pixel texture sample). - Real shadow mapping — the first concrete piece of "shadows/PBR,"
this project's own longest-standing unstarted lighting item, now
genuinely working. Deliberately scoped narrow rather than
generalized to every light and caster at once (see
kke::ShadowMap's own class comment for exactly what's in and out of scope): one directional light (the key light), one real shadow caster (CubeModule), single-tap sampling with a checked depth bias rather than PCF/soft shadows. A real, working single-caster proof, not a half-built system trying to cover every case and getting none of them fully right. - A genuine second render pass, not a shader trick:
kke:: ShadowMapowns a real depth-only render target, render pass (with correct subpass dependencies for entering/leaving it safely), and sampler.Modulegained a realrenderShadow()lifecycle method and a separate, honestly-minimalShadowRenderContext(not a reuse ofRenderContext— most of its fields wouldn't apply to a pass with no camera and no color attachment).Application's frame loop now runs a full shadow pass — begin, callrenderShadow()on every module, end — before the main color pass begins, since the shadow render pass's own final layout transition is what lets the main pass sample it directly afterward with no separate manual barrier. - A real architectural consequence, handled correctly, not
glossed over:
cube.fragis shared byCubeModule,PhysicsModule, andDestructionModule— updating it to unconditionally sample a shadow map meant all three needed their pipeline's descriptor set layouts andrender()calls updated too, whether or not that module's own geometry casts a shadow itself.PhysicsModule's ground plane, in particular, now receives real shadows through this shared shader with zero changes toPhysicsModulebeyond the mandatory descriptor set update — a direct, useful consequence of it already sharingCubeModule's own lighting shader. - Verified as genuinely dynamic, not a static decal: a real
screenshot showed a dark shadow shape cast onto
physics_demo's (andkke_demo's own FEMFX-enabled) ground plane, correctly positioned relative to the spinning cube. A second screenshot, taken a few seconds later with the cube still rotating, showed the shadow's own shape had genuinely changed — confirming this is recomputed live, every frame, not baked or cached. - A real depth-bias value, checked not guessed: 0.003 in this engine's own [0,1] depth range, enough to eliminate visible shadow-acne self-shadowing artifacts on the cube/ground test case without visibly detaching the shadow from its caster ("peter-panning") — confirmed against real screenshots, the same discipline as every other tuned constant in this project.
- Full comprehensive verification, both build configurations:
every demo (
kke_demo,physics_demo,rmlui_demo,imgui_demo) swept individually with real Vulkan validation layers active — zero validation errors, zero crashes, zero assertions, in each — exactly the kind of feature prone to subtle synchronization bugs, made a real confirmation rather than a hopeful one by those same validation layers this session installed earlier. BothKKE_ENABLE_FEMFXon and off configurations rebuilt clean from scratch: zero errors, zero warnings, all 40 tests passing, correct binary set, in each —CubeModule(and therefore shadow mapping) doesn't depend on FEMFX at all, confirmed by checking theOFFbuild specifically renders a correctly-lit cube with no physics ground plane present to receive its shadow, exactly as expected for that configuration, not by assumption. - What's deliberately still out of scope (see
ShadowMap.h's own comment): point-light shadows, shadow casting generalized to every module rather than justCubeModule, cascaded/multiple shadow maps for larger scenes, and PCF or other soft-shadow filtering beyond a single depth-comparison tap. - ~~
Rml::Debugger's "Outlines" tool crashes~~ Fixed — and the investigation ended up finding two more real bugs beyond the one being chased, both now fixed too. The honest, layered account: - The real breakthrough: installed real Vulkan validation layers
in this sandbox (it had none before), turning a bare, symbol-less
segfault deep inside the lavapipe driver into an exact, actionable
error:
vkCmdWriteTimestamp(): was called in VkCommandBuffer ... which is invalid because bound VkBuffer ... was destroyed. - Bug 1 (the original crash):
RmlVulkanRenderInterface:: ReleaseGeometry()/ReleaseTexture()destroyed their GPU resources immediately, with no check that the GPU had finished using them. Harmless for normal RmlUi content (which rarely releases geometry mid-session) but Outlines churns through far more temporary geometry per frame than anything else in this engine ever has, making the race far more likely to actually hit. Fixed with real deferred destruction — a queue tagged with the frame each resource was released on, only actually freed oncekMaxFramesInFlight(Renderer.h, currently 2) plus a safety margin of real frames have elapsed, matching the exact guaranteeRenderer's own per-frame fence wait already provides. Reverified: Outlines now renders real red borders around every visible element, exactly as intended, with zero validation errors across a sustained run. - Bug 2 (found by the same validation layers, unrelated to
Outlines): a broader regression sweep of
kke_demo's full showcase turned up validation errors during ordinary shutdown —vkDestroyBuffer(): can't be called on VkBuffer ... that is currently in use by VkCommandBuffer. Traced toApplication's own destructor: modules'shutdown()methods (which can destroy Vulkan resources directly) ran beforeRenderer's own destructor — the one that actually callsvkDeviceWaitIdle()— ever got a chance to run, since that only happens once every module is already torn down. This bug had presumably always existed, just never visible before — it never crashed, only mildly corrupted GPU-side state that happened not to matter for a process about to exit anyway. Fixed with an explicitvkDeviceWaitIdle()at the very start ofApplication::~Application(), before the module shutdown loop begins. - Bug 3 (introduced by the Bug 1 fix itself, caught before it
shipped): a real run combining both scenarios — Outlines
clicked, then a clean shutdown — hit a VMA assertion,
"Some allocations were not freed before destruction of this memory block!". The deferred-deletion queue from Bug 1's fix never gets swept again once the app is closing, and while a queued geometry deletion cleans itself up fine (its ownunique_ptrdestructor runs automatically), a queued texture deletion is a bare struct of raw Vulkan/VMA handles with no destructor at all — it would leak past process exit. Fixed by explicitly draining and destroying any remaining pending texture deletions inRmlVulkanRenderInterface's own destructor — safe to do immediately there specifically because Bug 2's fix already guarantees the GPU is idle by the time any module's shutdown (and therefore this destructor) runs. - Verified as a whole, not just each piece in isolation: the
exact combined scenario that caught Bug 3 — Outlines clicked,
geometry actively churning through the deferred-deletion queue,
then a real, clean shutdown — now runs with zero validation
errors and zero assertions. Every demo (
kke_demo,physics_demo,rmlui_demo,imgui_demo) swept individually with validation layers active after all three fixes: zero validation errors, zero crashes, zero assertions, in each. BothKKE_ENABLE_FEMFXconfigurations rebuilt clean from scratch: zero errors, zero warnings, all 40 tests passing, correct binary set, in each. - ~~Real plasticity support~~ Fixed —
enablePlasticitywas false everywhere, the same class of gap fracture was, closed the same rigorous way. Confirmed via readingFmComputeTetMeshBufferBounds's own signature that plasticity is structurally simpler than fracture: no separate per-tet output arrays needed at all, just the flag plus real (non-zero)plasticYieldThreshold/plasticCreepmaterial values, which already flowed throughFmInitTetState's own per-tet loop correctly. AddedspawnPlasticTetMesh()(mirroringspawnFracturableTetMesh()'s own pattern) and a "Spawn plastic cube" test button using the same verified 6-tet cube shape. - A real diagnostic mistake, found and corrected, not covered
up: the first verification attempt compared
FmGetVertRestPosition()before and after impact — a plausible- seeming signal that turned out to be entirely wrong. Reading FEMFX's own source (FEMFXUpdateTetState.cpp) showed plasticity is tracked as a per-tetplasticDeformationMatrix, not a change to the vertex rest-position array at all — and that internal state has no public accessor inAMD_FEMFX.hto read directly. Every threshold tried against the wrong signal (down to an extreme 0.0001) correctly showed nothing, which looked identical to "not working" and could easily have been misdiagnosed as a setup bug. - The real, corrected verification: measuring the distance
between the test cube's own vertex 0 and vertex 1 — exactly 1.0
unit apart at spawn. A real run showed it grow from 1.0000 past
1.02 and never spring back, with the growth rate genuinely
decelerating over time (each second's increase smaller than the
last) rather than diverging unbounded — consistent with
plasticCreep's own documented meaning (deformation accumulated per unit of excess stress, a rate) rather than a bug. - Same empirical-tuning story as fracture, briefer this time now that the right diagnostic existed: AMD's own reference value (2.5e6) wasn't reachable by this project's actual stress magnitudes either; landed on 2.0, confirmed working via the vertex-distance measurement above.
- No render-path changes needed, unlike fracture — a plastic object
never splits into new
FmTetMeshpieces, so the existing single- mesh path already renders it correctly as its shape changes. - Along the way, fixed a real, unrelated UI bug: the Physics ImGui panel was too narrow for three spawn buttons on one row (the third was clipped off-screen entirely), found the same way as everything else this session — a real screenshot, not assumed. Widened the panel's default size properly rather than leaving it to manual resizing.
- Full verification: both
KKE_ENABLE_FEMFXon and off configurations rebuilt clean from scratch, zero errors, zero warnings, all 40 tests passing in each, correct binary set in each, plus a live 10-second regression run ofkke_demo's own non-plastic physics object confirming identical settling behavior to every prior check. - ~~Real
<img>/background-imagesupport~~ Fixed —LoadTexturewas the one remaining stub inRmlVulkanRenderInterface(see its own class comment); it now genuinely decodes files withstb_image(already a real dependency elsewhere, no new one added), forcing RGBA8 output specifically to matchcreateTextureFromPixels's existingVK_FORMAT_R8G8B8A8_UNORMexpectation, then reuses that exact same GPU upload path font glyph textures already went through — no second, format-aware code path needed. A failed/missing file falls back to the same 1×1 white default untextured geometry already uses (logged viastbi_failure_reason(), not silently swallowed), rather than treating one broken image as fatal. - Verified two ways, independently, with real screenshots: added
a real PNG test icon to
rmlui_demo, rendered once through<img src="...">and once through RCSS's own separatebackground-image/decorator mechanism — both are genuinely different RmlUi code paths that happen to both callLoadTextureinternally, worth confirming independently rather than assuming fixing one fixed both. A zoomed screenshot shows both rendering the identical icon correctly. - A real, separate bug found and fixed along the way, not
related to image loading at all:
MaterialGridModuleunconditionally usedPhysicsModulewith noKKE_ENABLE_FEMFXguard — silently fine every time it had ever been built (always with FEMFX on), butkke_engineitself builds once per CMake configuration regardless of which demo enables FEMFX, and a defaultKKE_ENABLE_FEMFX=OFFbuild failed immediately once this file was actually part of one. Fixed by wrapping the whole module in the same guardPhysicsModuleitself already uses — the honest reflection of reality anyway, since this module has nothing meaningful to do without aPhysicsModuleto select materials for. - Full verification: both the default
OFFandFEMFX=ONconfigurations rebuilt clean from scratch afterward — zero errors, zero warnings, all 40 tests passing in each, correct binary set in each (physics_demo/kke_physics_benchmarkpresent only when FEMFX is on) — plus a live regression run ofkke_demo's full integrated showcase confirming no behavior changed. kke_demois now the real, integrated showcase it was always meant to be —LightingControlsModuleandMaterialGridModuleboth added, alongside everything it already had (physics with real fracture, destruction, marketplace, particles). Getting there surfaced two real, found-and-fixed problems, not a clean drop-in:- A real layout collision: both content modules originally
hardcoded the identical
left:40px; top:500pxposition — harmless while each only ever appeared in its own separate demo, a direct overlap the moment both needed to coexist in one. Fixed properly: added realleft/topconstructor parameters to both (applied viaElement::SetPropertyafter load, not baked into the RML string), defaulting to each module's original position so every existing call site keeps rendering exactly where it always did. - A second, non-obvious collision, found by screenshot not
assumption: the first attempt placed
MaterialGridModuleat(820, 500), assumed-empty space belowMarketplaceUiModule's own panel. It rendered completely invisible with no errors at all — confirmed via a real diagnostic log that the reposition itself succeeded, then a real screenshot revealed why: Marketplace's own opaque background actually extends continuously well past y=500 (four stacked game cards), silently hiding anything placed underneath it. Fixed by wideningkke_demo's window from 1280×720 to 1600×900 (genuinely warranted — this many real panels needs the room, not a workaround) and stackingMaterialGridModulebelowLightingControlsModuleinstead, in the new vertical space. - A third, smaller layout bug:
MaterialGridModule's own five cards wrapped to a second row and clipped off the bottom of the window — the panel width was a few pixels too narrow for five cards at their original size. Fixed with tighter, verified card dimensions (70px cards, 460px panel) that fit five in one row with real margin to spare, not just barely. - Verified with real interaction after every fix, not just
visual inspection: clicked "Glass" (the previously-clipped,
rightmost card) and confirmed via a zoomed before/after screenshot
that it genuinely highlights as selected while "Wood" correctly
deselects — the same real click-through-to-
PhysicsModule:: selectedMaterial()path verified when this module was first built. - Full regression pass:
physics_demo(which still usesMaterialGridModule's original, unmodified default position) re-verified with a real sustained run afterward, same correct settling behavior as every prior check. kke::MaterialGridModule— a real extraction-shooter-style grid menu, wired to real state, not decoration. Five material preset cards (Wood, Stone, Iron, Rubber, Glass) with genuinely differentiated values matchingMaterial.h's own documented intent (glass brittle and close to its yield point, rubber barely breaks at all) — clicking one actually changesPhysicsModule:: selectedMaterial(), which the next "Spawn tetrahedron" click genuinely uses. AddedPhysicsModule::selectedMaterial()as real, settable state for this (mirroring howApplication::lighting()already works), replacing that button's old hardcoded material. Deliberately does NOT touch "Spawn fracturable cube" — that button's own material has a specifically, empirically tuned fracture threshold (see the fracture entry below) that an arbitrary preset swapped in here could quietly break.- A real bug found and fixed while testing it, not assumed
correct: the first version did nothing when clicked. Traced it
directly: each card has child elements (a color swatch div, label/
stat
<p>tags), and a click lands on whichever child element is actually under the cursor —Event::GetTargetElement()returned that child, not the card div my listener was attached to, so the id check never matched anything. Fixed withEvent::GetCurrentElement()instead, which always returns the element the listener is actually registered on regardless of which child inside it was clicked. Verified after the fix with a real screenshot: clicked "Iron," watched it highlight and "Wood" un-highlight, "Selected: Iron" text update, then spawned successfully afterward. - Real fracture support — genuinely working now, found by reading
AMD's own vendored sample code and FEMFX's own source, not
guessing. This was the single biggest, most-repeated gap this
project had honestly flagged (
enableFracture=falseeverywhere, no exceptions) — closed properly, not just flipped on: - Researched what "showing off FEMFX" actually means first:
external/FEMFX/samples/common/TestScenes.cpp, vendored alongside the library itself, is AMD's own real reference demo. It fractures wood panels with a fired projectile, piles up dozens of soft-body blocks and ducks, lets material parameters change live (including melting), and stacks rigid and deformable bodies together — confirming a handful of falling tetrahedra never represented real FEMFX capability, and setting the actual target. - The real missing piece, traced from AMD's own code:
FmComputeTetMeshBufferBoundsandFmCreateTetMeshBufferboth takeFmFractureGroupCounts/tetFractureGroupIdsoutput arrays — this project's spawn code always passednullptrfor both. That's the literal, complete reason fracture never worked anywhere in this codebase before now, not a deeper bug. Added a realspawnFracturableTetMesh()API (kept separate fromspawnTetMesh()— fracture needs genuinely extra setup and a more expensive render path, worth keeping visible at the call site, not hidden behind a default parameter). - A real render-path rewrite, not a small patch: a fractured
object can split into multiple independently-moving
FmTetMeshpieces at runtime (FmGetNumTetMeshes()can grow past 1), and per FEMFX's own setup docs, vertex count itself "may grow with fracture." Fracturable objects now size their buffers to the reserved maximum capacity and rebuild both vertex and index data from each current sub-mesh's actual topology every frame, instead of uploading once at spawn time. The existing, already-verified non-fracturing path is completely untouched — confirmed by regression testingkke_demo's own physics object afterward, exact same settling height as every prior verification. - A real, humbling tuning journey, honestly recorded: getting an
object to actually fracture took far more empirical work than
expected. A simple gravity drop from this demo's usual spawn
height didn't generate enough stress to fracture even at
fractureStressThreshold=100(already assumed "very low" against a 5×10⁶ stiffness material) — matching AMD's own scene design directly: their reference wood panels get fractured by a fired projectile, not gravity. Added a real initial velocity parameter tospawnFracturableTetMesh()(a genuine, if simple, stand-in for "thrown hard," usingFmInitVertState's own velocity parameter, previously always zero everywhere in this codebase) and traced the entire FEMFX call chain by reading its source —FmUpdateScene→FmUpdateTetStateAndFracture→ the actualmaxStressEigenvalue > fractureStressThresholdcomparison inFEMFXUpdateTetState.cpp— to confirm the setup was correct throughout and this was genuinely a threshold-scale question, not a bug. The real working value ended up being 10.0 — this specific material's actual stress values under impact are apparently much smaller in magnitude than AMD's own reference examples (5×10⁵ to 10⁶ for their wood panels), most likely because those are larger, heavier objects under a harder hit. - Verified two ways, not just visually: logged
FmGetNumTetMeshes()directly infixedUpdate()— "fracturable object (handle N) has split into 2 pieces," consistently reproducible across repeated real runs — and confirmed a real, if subtle, visible crease across the object where a perfectly intact cube wouldn't have one. - A real UI entry point to try this yourself:
physics_demo's "Spawn fracturable cube" button, using a real 6-tetrahedron cube decomposition (a single tetrahedron has nowhere to break into — fracture splits along existing tet boundaries, so meaningful fracture needs genuinely connected multi-tet geometry). kMaxObjectsraised from 8 to 64 — the old cap was never meant to represent a real ceiling, just the smallest number that proved the spawn API worked at all; AMD's own reference scenes show piles of dozens of objects at once. Confirmed live:physics_demo's own UI now reads "Objects: N/64."- ~~The three purposeless bottom boxes~~ Fixed — genuinely
removed, not just restyled. Those boxes were
UiModule's own hardcoded "test document," loaded unconditionally into every demo usingUiModule, left over from the original slice that first proved RmlUi text rendering worked.UiModuleis content-agnostic now — it only owns the RmlUi Context/render pipeline/input forwarding, matching what its own class comment already said it should be. In their place:kke::LightingControlsModule, a real, new, reusable engine module (any demo can opt in, the same wayMarketplaceUiModulealready works) — genuine sliders and preset buttons wired directly toApplication::lighting(), the real multi-light system built earlier. Added tokke_demo_gamespecifically, where the lit cube makes the effect immediately visible. Verified with real interaction, not just layout: clicked "Dramatic (low ambient)" and watched the cube's lit/shadowed contrast change completely on screen, sliders update to reflect the new state, then clicked "Reset to default" and watched it return exactly to the original appearance. A real, if minor, C++ gotcha hit and fixed along the way:std::unique_ptr<ForwardDeclaredType>as a class member needs an out-of-line destructor defined where the type is complete — even that wasn't enough here (still failed from a different translation unit including only the header), so the fix is a plain raw pointer with manual new/delete instead, documented inLightingControlsModule.hfor whoever hits the same thing next. Confirmed no regression elsewhere:rmlui_demo(which doesn't add the new module) now correctly shows a clean bottom half with no leftover boxes, all of its own existing content untouched. Rml::Debugger's "Outlines" tool crashes — found incidentally while fixing the range slider (see below), not chased down: clicking it segfaults deep insidelibvulkan_lvp.so(lavapipe, the software Vulkan driver this sandbox uses), confirmed via a realgdbbacktrace on a background thread.rmlui_democurrently initializes the debugger but keeps it hidden (Rml::Debugger::SetVisible(false)) specifically to avoid this. Likely lavapipe-specific rather than a real engine bug, but genuinely unconfirmed on real hardware — worth a real look before assuming either way.- RmlUi demo: three real interaction bugs found and fixed, one still open. All three verified with actual clicks/state changes, not just visual appearance:
- Tabset content beside the tabs, not under them — both
tabset tabsandtabset panelneeded explicitdisplay: block, confirmed against RmlUi's own working sample (Samples/assets/invader.rcss), not guessed. - Checkbox/radio invisible and unclickable — found while investigating the slider: neither had any CSS at all, so they rendered at effectively zero size. Not a hit-testing bug — there was genuinely nothing there to click. Fixed with real sizing; verified by clicking "Mode B" and watching "Mode A" correctly deselect (real radio-group exclusivity, not just a color change).
- Dropdown with no background or hover — only the outer
selectelement had ever been styled; the parts a user actually sees and clicks (selectvalue,selectarrow,selectbox,selectbox option) inherit nothing automatically. Fixed with real styling for all of them. - ~~The range slider didn't respond to any click or drag~~ Fixed
— with a real, code-level root cause, not more CSS. Wired in
RmlUi's own debugger (
Rml::Debugger, already built as part of this project's existing RmlUi fetch — confirmed unconditional inSource/CMakeLists.txt, no new dependency needed) to investigate properly rather than keep guessing from screenshots. Its own "Outlines" tool immediately crashed — a real, reproducible segfault confirmed viagdbbacktrace, deep inside the lavapipe software driver itself — set aside as a separate, likely sandbox-specific issue, not chased further. The real fix came from adding temporary diagnostic logging directly intoUiModule's own mousedown handling, printing exactly which elementContext::GetHoverElement()resolves each click to. Across eleven different Y coordinates spanning the entire visible track/thumb area, every single click resolved to the parent<input class="range">itself, never to the internalslidertrack/sliderbarelements. Traced this to RmlUi's own source:WidgetSlider::Initialise()adds both as children viaAppendChild(..., /*dom_element=*/false)— confirmed by readingWidgetSlider.cppandElement.cppdirectly — andWidgetSlider::ProcessEvent()specifically checksevent.GetTargetElement() == track, a check that can never succeed given what event targeting actually resolves to in this integration. Rather than patch RmlUi's own vendored widget internals,UiModulenow handles the click directly at the one point confirmed to actually receive it — the parent element — computing the intended value from click position and setting it through the same publicSetValue()API a working slider would end up calling internally. Verified with real screenshots: a sequence of clicks across the full track correctly moves the thumb to each clicked position, and a full regression pass ( checkbox, radio, tabs, andkke_demo's marketplace) confirmed nothing else broke from a fix living in sharedUiModulecode. - ~~Marketplace card text running together unformatted~~ Fixed —
and there was already an honest comment in the code flagging this
exact symptom, left by an earlier pass that verified it wasn't a
data/escaping problem but didn't chase the real cause. Same root
cause as the tabset bug above: RmlUi has no built-in "p/div default
to block" behavior the way a browser does — that comes from a
stylesheet RmlUi's own samples happen to link in, not something
built into the engine for every document.
MarketplaceUiModule's generated RML never linked one, so every<p>/<div>defaulted to inline. Fixed by addingdisplay: blockdirectly to each generated element's inline style. Verified with a real screenshot: three clearly separated cards, proper title/id/description/tags hierarchy, where before everything ran together as one unbroken block of text. - ~~Confirm the CI workflow actually runs on GitHub's infrastructure.~~
Confirmed — and it found a real bug on the very first real run, not a
clean pass. The "Headless smoke test" step failed with "Process
completed with exit code 124" despite that step's own script being
written specifically to treat 124 (from
timeout 8 ./kke_demo) as success. Root cause, confirmed by reproducing it locally under the exact samebash -eGitHub Actions uses: errexit aborts a script immediately when a bare command on its own line returns non-zero —timeout's 124 killed the script beforeexit_code=$?or the check meant to accept 124 ever ran. Fixed withset +e/set -ebracketing just that one command, verified by reproducing both the broken and fixed behavior locally against the exact same shell invocation before pushing anything. The build (625/625 objects) and all 40 tests had already passed cleanly on the real runner before this — the engine itself was never the problem, only this one script's exit-code handling. - ~~Render-mesh-to-tetrahedra vertex skinning bridge~~ Fixed — see "Render bridge" above. (True general skinning — arbitrary render meshes onto many tets — is still future work; what exists now is the minimal "the tet's own 4 vertices are the render mesh" version.)
- ~~A general "spawn object with mesh + material" API~~ Fixed — see "General spawn API" above. Still only one spawnable shape; the API itself (position, material, runtime-callable) is real and general.
- ~~TetGen-based
.FEMauthoring without Houdini~~ Resolved by choosing CGAL instead — see "Content pipeline tetrahedralization" for the full technical and licensing reasoning. A real repair pipeline (soup repair, orientation fixing, triangulation, border stitching) now handles genuinely common real- world defects — verified against actual broken test meshes, not assumed — closing most of what this entry originally flagged. What's still genuinely open: no voxel-grid pipeline for real missing geometry (a true hole, as opposed to a fixable winding/triangulation defect, correctly refuses with a clear error rather than crashing, but still can't be tetrahedralized), and no OBJ/FBX/glTF import (needs assimp, feeding into this same repair pipeline). - ~~The ImGui resize assertion crash~~ Fixed. Real root cause,
found by reading
Application.cpp's frame loop, not by guessing:m_debugUi->beginFrame()(which callsImGui::NewFrame()) ran unconditionally, before checking whetherm_renderer->beginFrame()would even succeed. When the swapchain went out of date mid-resize and that check failed, the whole rendering block — includingImGui::Render()— got skipped for that frame, so the next frame'sNewFrame()fired with no matchingRender()in between, which is exactly what the assertion was complaining about. Fixed by movingbeginFrame()and therenderUi()calls inside the success branch, so they only ever run for a frame guaranteed to complete. Verified by reproducing the exact crash first (a single resize killed a real running session), then confirming the fix survives four rapid resize cycles in a row without issue, followed by a full test-suite pass. - ~~A dedicated, visually legible physics demo~~ Done — see
"
games/physics_demo— a dedicated demo" above for the real camera/ scale/ground-size work this took, verified with real screenshots at each step. Now genuinely being used as the actual demo, not deferred. - ~~ImGui showcase demo~~ and ~~RmlUi showcase demo~~ Done —
see "The demo suite" section above for both. The shared-shader-target
and shared-font-copy CMake fixes made while building
physics_demopaid off immediately here — both new demos reuse shader files already used elsewhere (grid.vert/frag,rml_ui.vert/frag) and hit no collision at all, confirming those fixes were real and general, not narrowly patched for one case. What was newly found and fixed here: a badly-overlapping first RmlUi layout, fixed by checking the other panels' actual hardcoded positions in their own source rather than guessing. - The rest of the demo suite — a VulkanProfiler demo with sample analysis, chunk-loading/streaming (dual-viewport), culling (dual-viewport) — still genuinely unstarted. The chunk-streaming and culling demos in particular are not "just wrap existing capability in a demo" the way the two done so far were — this engine has no chunk/streaming system and no frustum/occlusion culling at all yet, so those two are real subsystems to design and build, not just demo wrapping.
- ~~Real multithreading for the physics task system~~ Done — see
"Real multithreading" in "Physics: AMD FEMFX integration" for the
full account, including a real correctness risk (per-worker scratch
buffer indexing) and a real deadlock, both found and fixed before
trusting it.
tools/physics_benchmarkexists specifically so the actual speedup can be measured on real multi-core hardware — this sandbox's single core can only prove correctness, not performance. - A physics stress-test demo. Raise
kMaxObjectswell past 8 and see what actually happens — the particle system already proves 20,000 GPU-simulated particles; physics has never been pushed anywhere near that. Real multithreading now exists (see above) but hasn't been proven to help at scale — only proven correct — since this sandbox has no second core to show a speedup on. Already have one real data point pointing at real cost: a single 401-tet mesh dropped frame rate from ~27 FPS to ~4 FPS in this sandbox's software-rendered, single-core environment. vkGetProfilerFrameDataEXTintoStatsModuleand spdlog — see "GPU profiler (VulkanProfiler) integration." Written and functional in structure, but disabled behindKKE_QUERY_GPU_PROFILER_DATAafter a real, diagnosed crash inside the layer's own code when calling it from application code in this (lavapipe) environment. Re-enabling on real hardware or a newer layer version is a one-line change away.- RmlUi input wiring would also unlock testing the marketplace card's eventual "launch this game" interaction, once that exists.
- Migrate remaining
std::cout/std::cerrcall sites to spdlog — see "Logging"; only a representative few are converted so far. checkHardwareRequirements()has no compatibility with dynamic content — a game whose particle count, resolution, or texture budget changes at runtime (most real games) will drift away from whatever static numbers itsgame.jsondeclared. The check is a startup-time snapshot against a fixed declaration, not a live monitor.- CMake's
POST_BUILDcopy ofgame.jsononly fires when the target actually rebuilds — editing onlygame.jsonwithout touching any source file won't trigger the copy on the nextcmake --build; found this while testing the hardware check (had to copy the file by hand to verify the warning path). Worth a proper fix (e.g. depending on the manifest file explicitly) rather than working around it by hand again. - ~~RmlUi slice 2: a real Vulkan
RenderInterface~~ — done.RmlVulkanRenderInterfacecompiles RmlUi's geometry intoBuffers and draws it through a real pipeline (shaders/rml_ui.{vert,frag}), verified with an on-screen test document. - ~~RmlUi slice 3: textured rendering~~ — done, for glyph atlases.
GenerateTexture(the path text rendering uses) creates a real VMA-backed image +VkSampler+ descriptor set; untextured draws share the same pipeline via a persistent 1×1 white default texture. Verified with real anti-aliased, word-wrapped text on screen. - ~~
LoadTexture(image files) is still a stub.~~ Done — seeBUGS.mdBUG-004 for the fix andROADMAP.md's Rendering section for current state. This was the path<img>andbackground-image: url(...)come through; realstb_imagedecoding now feeds the sameGenerateTexture-style upload path text rendering already used. - ~~RmlUi slice 4: input wiring~~ — done.
Module::onEvent()is a new generic lifecycle hook (any module can use it, not just RmlUi);UiModuleforwards mouse (move/buttons/wheel), a common-keys keyboard mapping, and text input intoRml::Context. Verified on screen with a:hoverstyle change (a box turning white under the cursor and back). Building this surfaced a real, non-obvious bug worth recording: every hit-test was resolving tobody/#rootregardless of cursor position, traced (by instrumentingContext::GetHoverElement(), not by guessing) toMarketplaceUiModule's full-screen transparent body swallowing hit-tests across the entire window for every document sharing thatRml::Context— fixed withpointer-events:noneon that body. Worth remembering as more panels share one context. - ~~RmlUi slice 5: resize handling.~~ Fixed — see "What's not done yet" above for the verification details. Kept here, struck through, since the reasoning (why a per-frame check rather than reacting to the SDL resize event directly) is worth keeping visible in the history, not just the fact that it's done.
- RmlUi + security: once slice 3 exists and a chat system is even contemplated, establish the rule in code (not just convention) that any user-generated text is inserted as an RmlUi text node, never parsed as markup — this is the actual injection-risk boundary HTML-based UI introduces, and it's worth a real safeguard (e.g. a wrapper function that's the only sanctioned way to insert untrusted strings) rather than "remember not to do that."
- Windows and macOS (MoltenVK) build verification — Linux-first per the original plan; nothing built so far is known to be Linux-only, but nothing has been verified elsewhere either.
- Texture loading via stb_image — stb is fetched, unused; likely shares plumbing with RmlUi slice 3 above (both need a VMA image + sampler path) once either one is built.
- A real network transport (sockets) behind
NetworkModule— currently measures payload sizes only, sends nothing. - Icon integration per platform.
- ~~Marketplace UI~~ — done, minimally. See "Game folder convention & marketplace" above for exactly what this does and doesn't cover yet (non-interactive, scans once at startup, no compatibility checking).
- Marketplace compatibility checking —
engine_version/modulesfields ingame.jsonare recorded but not validated against what the running engine build actually provides. - Dynamic multi-game builds — CMake currently hardcodes one
add_subdirectory(games/kke_demo_game); scanning agames/directory and building whatever's found is separate, unstarted work fromMarketplaceIndexscanning it at runtime. - Marketplace sandboxing — the most important open item on this whole list. See "Game folder convention & marketplace" above in full; the short version is that native-C++ game folders have zero isolation, and a real marketplace needs either a sandboxed scripting layer (Lua, see below) or actual process/OS-level isolation before it can safely run third-party content.
Foundational systems (needed by nearly everything below)¶
- Physics. Every game genre discussed for this engine needs it. Whichever library or from-scratch approach is chosen, determinism should be a design constraint from the start if rollback netcode is ever a goal (see below) — retrofitting determinism into physics code written without it in mind is much harder than designing for it.
- Animation (skeletal). Needed for fighting games, MMORPGs, cutscenes, and a shooter with any character models at all.
- Input abstraction beyond raw SDL events. Needed for rebindable
controls and — cheaply, once it exists — split-screen local
multiplayer (N input devices, N cameras/viewports from one swapchain;
Rendereralready separates "begin render pass" from "draw," which is the right split for multiple viewports). - Audio. Not started at all.
- General save/serialization. Only exists narrowly today as
INetworkReplicable(built for network replication, not save games, though the two overlap conceptually — worth revisiting once both exist whether they should share a mechanism). - A real asset pipeline. Nothing currently loads external textures/models/audio.
- Determinism discipline + a state snapshot/restore mechanism.
Prerequisite for rollback netcode specifically (see below) — no
floating-point divergence across platforms in gameplay code, no
reliance on unordered-container iteration order, everything
gameplay-relevant in
fixedUpdate(), and a cheap way for every participating module to snapshot/restore its entire state on demand.
Networking — not one system, at least two¶
Read Gaffer On Games and study
GGPO before implementing either of the
two items below — see AI_GUIDE.md's "Further reading" for why each is
relevant to which one.
- Client-server, authoritative (MMORPG, chat, one shared destructible world, a shooter): server owns truth, clients predict and reconcile. This is the netcode style to build first — simpler to reason about than rollback, and needed for the MMORPG/chat/shared-world demos before rollback is touched at all.
- P2P rollback (fighting game): needs the determinism/snapshot prerequisite above. Doesn't scale past a handful of peers, so it is not the same system as the client-server case, even though both will likely share serialization/transport plumbing underneath. This is the single hardest item on the whole roadmap — general-purpose-hard, not just hard for this engine specifically.
- Chat system — needs a transport to exist first (either networking style); the UI side depends on the RmlUi slices above plus the text- insertion safety rule mentioned there.
- Authority/reconciliation for conflicting
INetworkReplicablestate. The interface supports it (deserializeReplicatedState()exists); nothing calls it yet because there's no real transport to receive conflicting state from. - Running multiplayer demos on one machine — for testing
client-server/P2P, this means the eventual transport supporting
multiple local instances on
127.0.0.1with different ports; a testing convenience to design for, not a separate feature.
Procedural generation — two different problems sharing one pattern¶
Procedural Content Generation in Games (free
book — see AI_GUIDE.md's "Further reading") covers the actual
algorithm families for both items below in real depth.
- Streaming/open-world (survival game): noise/heightmap-based terrain, generated once from a seed, with chunks streamed in/out by player position. Needs a chunk-streaming system; no generation algorithms exist yet.
- Discrete/on-demand (dungeon crawler): algorithmic layout generation
(room graphs, BSP, wave function collapse, etc.), generated per-floor
on demand rather than streamed. Different algorithms, same underlying
primitive as
DestructionModulealready demonstrates: seed in, deterministic content out.
Genre-specific systems¶
- Cutscene/timeline/QTE system (movie-like game): trigger events at specific times, blend camera cuts, wait for player input within a window. Fairly self-contained once animation and input abstraction exist; not started.
- Slow-motion physics bullets (shooter): a good architectural fit
already —
FixedUpdateContext::fixedDtcould be scaled down during a slow-mo window without touching render frame rate at all, since render-rateupdate()and simulation-ratefixedUpdate()are already separate. The actual gap is physics itself; a bullet is just a fast physics body with a collision callback. - Flying sim: mostly stresses render distance/LOD and needs its own
free-fly camera module (same
Modulepattern asOrbitCameraModule, not a modification of it). - Split-screen: see "input abstraction" above — this is the item on the whole list that's closest to "just needs the input+viewport work," not a new rendering or networking system.
The Roblox-like ambition (scripting for non-programmers)¶
This is also the actual path to a marketplace that's safe for
third-party content — see "Game folder convention & marketplace"'s
sandboxing caveat above; scripted (not compiled) game folders are the
realistic route to that, not a smaller version of what exists today.
When that binding layer gets built, its errors should surface through
kke::EngineError (see "Clear errors for scripters" above) — that
plumbing already exists and is verified, specifically so this doesn't
need its own error-reporting mechanism invented later.
Lua is fetched by CMake and called by zero lines of code today. Getting
from here to "non-programmers can script games" needs, at minimum: (1)
actually binding the Module lifecycle to Lua, not just C++; (2) a
sandboxing story — embedded Lua can call into anything exposed to it, so
"safe to run untrusted/marketplace scripts" is a security design
question, not a given, and matters a lot for a Roblox-marketplace-like
goal; (3) probably a visual scripting layer or at minimum a much
friendlier API than the raw C++ Module interface translated 1:1. This
is a project-sized effort on its own, not a module.
Combining systems into one game¶
Whether physics + networking + destruction + procedural generation can
all coexist in one game is mostly a module-discipline question, not
a rendering or networking one: if each is built talking through
capability interfaces (as DestructionModule/NetworkModule already
demonstrate) rather than hard-coding assumptions ("physics owns the only
rigid bodies," "networking assumes exactly 2 players"), combining them
stays additive. The module system makes that possible; it doesn't make
it automatic — whoever writes each future module has to keep to the
pattern.