Game world reference¶
This small game demonstrates the first implementation of the world architecture. Move the orange player with WASD, attack nearby red guards with Space, and reach the green exit to load the other room. P pauses, N advances one neutral tick while paused, and R restarts. Click or focus the browser canvas before using keyboard controls. The green and grey rectangles behind the actors are scatter placements generated for each room from the session's world seed.
The reusable engine module contains only identity and component storage. This application owns the level schema, component roster, simulation policies, input bridge and session. It draws through the engine Renderer, the same renderer the Editor's World viewport uses. The model builds and tests with the normal native presets even when the graphical application is disabled.
The renderer now consumes the C0 camera sample and checked view adapter.
The authored fixed shot remains the default. Use native --follow-camera or
browser ?camera=follow to opt into immediate planar player follow. Camera
extraction uses the same interpolated target as scene draws and does not change
authoritative gameplay. The existing level schema and minimum-width resize rule
are preserved.
Run the application¶
Prepare the normal pinned native tools and dependencies with ./init.sh --cli.
The optional renderer also requires the pinned Slang/SPIR-V tools:
./scripts/shader-probe bootstrap
out/host-tools/venv/bin/cmake --list-presets=configure
out/host-tools/venv/bin/cmake --list-presets=build
out/host-tools/venv/bin/cmake --list-presets=test
out/host-tools/venv/bin/cmake --preset linux-clang-development -DLUDUS_BUILD_WORLD_DEMO=ON -DLUDUS_SLANG_COMPILER="$PWD/out/shader-tools/slang/bin/slangc" -DLUDUS_SPIRV_VALIDATOR="$PWD/out/shader-tools/spirv-tools/usr/bin/spirv-val"
out/host-tools/venv/bin/cmake --build --preset linux-clang-development --target ludus_world_demo
out/build/linux-clang-development/apps/world_demo/ludus_world_demo
The CMake cache stores local tool paths; do not commit those paths. Existing CMake
Tools settings select the pinned CMake and selectable presets. --headless runs
120 simulation ticks without a window; with --headless, --frames N runs N
ticks instead. Without --headless, --frames 10 presents ten native frames and
exits through ordinary shutdown. --trace-output <path> captures the whole run
with FoundationProfiling and writes a Perfetto trace there; it fails in a
profiling-disabled Release build and is not available for the browser window.
Runtime profiling runs these modes on a
schedule.
The native loop is paced.
The FIFO swapchain paces presented frames. Frames that present nothing (startup,
loading) wait at 60 Hz instead of polling. While the window is minimized or
occluded, every frame waits at 10 Hz (or a lower cap) and the simulation pauses;
showing the window restarts pacing at once. --frame-rate N
caps every native frame at N per second (1 to 1000); with --headless it paces
each tick iteration without changing the ticks run or the replay hash.
--pacing-report logs wake lateness, frame-start intervals and EndFrame-return
intervals at exit; it requires --frames. The browser build rejects both
options, because its presentation callbacks already pace the page.
For the browser, prepare ./init.sh --cli web-emscripten-development --preset-only,
enable LUDUS_BUILD_WORLD_DEMO=ON in that preset's local CMake cache, and run
./scripts/build web-emscripten-development. Serve
out/build/web-emscripten-development/apps/world_demo/ over HTTP. The graphical
application is opt-in so normal native onboarding does not require shader tools.
Browser builds use the existing pinned Slang, SPIRV-Tools and SPIRV-Cross helpers.
macOS Metal¶
The graphical demo is supported on macOS. Its level parser uses Ludus-owned
binary32 decimal conversion, so floating-point std::from_chars availability
is no longer a requirement. Prepare the native SDK/dependencies with init.sh
and bootstrap shader tools as above, then configure the engine workspace:
out/host-tools/venv/bin/cmake --preset macos-clang-development -DLUDUS_BUILD_WORLD_DEMO=ON -DLUDUS_SLANG_COMPILER="$PWD/out/shader-tools/slang/bin/slangc"
out/host-tools/venv/bin/cmake --build --preset macos-clang-development --target ludus_world_demo
out/build/macos-clang-development/apps/world_demo/ludus_world_demo
For Debug, substitute macos-clang-debug in both commands and the executable
path. In VS Code, configure that engine preset with LUDUS_BUILD_WORLD_DEMO=ON
and the prepared Slang compiler path, then select ludus_world_demo as the
launch target. Tests with world in their names are separate executables.
--headless runs the simulation without a window; --frames 3 renders three
frames through Cocoa/Metal and exits. No installed SDK consumer project is
needed for this engine application.
Windows Direct3D 12¶
Use an x64 Visual Studio 2022 developer shell with MSVC 14.44 and Windows SDK
10.0.26100.0. Prepare CMake, Ninja and the pinned Conan dependencies as in the
Ninja runtime step. Set
LUDUS_WINDOWS_DEPENDENCIES to the CMakeDeps output directory containing
Catch2, FreeType and HarfBuzz metadata for RelWithDebInfo. Configure and build
from the source checkout.
python scripts/shader-probe bootstrap
cmake --preset windows-msvc-runtime
cmake --build --preset windows-msvc-runtime --target ludus_world_demo ludus_world_frame_renderer_tests
python scripts/pack apps/world_demo/levels out/world-levels.lupak
out/build/windows-msvc-runtime/apps/world_demo/ludus_world_demo.exe --level-pack out/world-levels.lupak first-room.world.json
--level-pack <archive> <relative-entry> loads the starting room from an
existing version-1 Ludus pack. Native Linux and macOS builds accept the same
pack. The named entry goes through the same World validation as --level and
the embedded room. The existing restart and exit actions still select the
embedded rooms. On Windows the player logs the selected Direct3D 12 adapter
and whether it is hardware or software.
For a bounded check, add --frames 3 --follow-camera. The
ludus_world_packed_room_d3d12 test compares loose and packed replay hashes,
requires three presented frames and rejects a corrupt pack. The
ludus_world_frame_renderer_pixels test reads back the authored room's
raster output. CI sets LUDUS_TEST_REQUIRE_WARP=1 for those checks. For a
physical GPU run, remove that environment variable and verify that the player
logs a hardware adapter. Exercise WASD, Space, pause, minimize and restore,
resize, and close on the same packed room.
Edit levels and gameplay¶
Edit first-room.world.json or
second-room.world.json, then build. CMake embeds these files;
there is no duplicated C++ level literal. ReadLevel owns the result, rejects
unknown/duplicate fields, and validates kinds, references and procedural visual
keys. IDs use lowercase ASCII letters, digits and hyphens, with at most 31 bytes.
The Worlds share the editor’s version-1 entity/component envelope and the
game-owned ludus.world-demo.v1 schema. ReadWorldLevel uses the bounded Ludus
JSON parser during loading; it owns the resulting typed Level and releases all
parser storage before returning. Input is limited to 64 KiB, 49 authoring
entities, bounded nesting/strings and no duplicate or unknown fields.
ReadWorldLevel also reads version-2 World records, which carry a document UUID
and one UUID per entity, keep each id as label, and name enemy targets by UUID.
It resolves targets back to labels, so gameplay and the replay hash do not change.
The committed rooms stay version 1. identity-migration.json
holds their committed UUIDs, and ludus-cook identity apply produces the version-2
form without minting; see UUID authoring records.
Use the World workflow for transforms, player/enemy/exit fields, collision boxes, camera/bounds and seed. The same saved World feeds both trusted preview and the sample player.
Scatter placements¶
Each room may author a room-scoped scatter of static placements, the first consumer of the addressed randomness outside the renderer and slice P0 of the procedural content generation architecture. The recipe is ordinary World content, so the Editor inspects it with the same schema-driven fields as every other component:
| Component | Entity count | Properties |
|---|---|---|
scatter |
one per world | units_per_metre (1 to 1000 lattice units per metre), the region min_x/min_y/max_x/max_y in metres inside the level bounds, and keep_out_margin in metres |
scatter_layer |
one to four | layer (stable ID 0 to 255, unique in the room), cell_size and candidates per cell (1 to 64) in lattice units, min_scale/max_scale in thousandths, and an optional harvestable boolean (default false; at most one layer per room) |
scatter_variant |
one to four per layer, in document order | layer it joins, exclusion_radius in lattice units (at most the cell size), drawn half_x/half_y in metres at scale 1000, and red/green/blue |
The kernel works on an integer lattice; units_per_metre converts authored
metres (rounded to the nearest unit) and converts generated positions back for
drawing. Keep-outs are not authored separately: every player and enemy spawn
point, every exit and every collision box, each expanded by keep_out_margin,
excludes candidates from all layers. Layer IDs are persistent: a retired ID is
never reused, because it is part of every placement identity together with the
room ordinal, cell and candidate index.
The room ordinal comes from the checked-in catalog in
room_catalog.h, which maps each
level's document UUID (the committed values in identity-migration.json) and,
for version-1 records without a UUID, its settings id to a nonzero uint16
that is never reused. The random domains (placement, variant, transform, regrow
and cosmetic) are the checked-in manifest in
random_domains.h; both tables
assert their uniqueness at compile time. The session owns an explicit
WorldSeed with a fixed default and records the value each world was prepared
with (GetWorldSeed); the level's authored seed stays content data and does
not influence placements, so two rooms never repeat each other's scatter.
Placements are generated once, when a room's candidate world is prepared, and
stored as compact records (GetPlacements), never as entities. Preparation
performs the only allocations: one scratch buffer for every candidate of every
layer and the room's placement storage. The tick and extraction paths allocate
nothing. Extraction draws every placement behind the entities, scaled and
rotated from the generated transform, and samples only the cosmetic domain at
event zero for a per-placement tint. Placements, the scatter recipe, the room
ordinal and the world seed enter the replay hash; cosmetic tints enter nothing,
so rendering, frame pacing, interpolation and extra extractions never change it.
Loading rejects an unusable recipe with nothing published: a repeated layer ID or
name, a variant naming no layer, an empty region or one outside the level
bounds, an exclusion radius above its cell size, an inverted scale range, more
than 4096 candidates (MAX_ROOM_SCATTER_CANDIDATES) or more than 128 accepted
placements (MAX_ROOM_PLACEMENTS), or marks more than one layer harvestable.
A level with a scatter whose room is not in the catalog fails preparation with
InvalidConfiguration; the active world is preserved. Levels without a scatter
component need no catalog entry and draw nothing extra. The legacy v1 writer has
no scatter representation, so WriteLevel omits it.
Harvesting and regrowth¶
A scatter_layer marked harvestable lets the player harvest its placements.
When the player overlaps one during a tick, the world records a tombstone keyed
by the placement's packed scatter scope (its persistent identity) in the Commit
phase. A tombstone is a world mutation, not a random draw. Because regeneration
reapplies the session's tombstones when a room is prepared, a harvested placement
stays harvested across leaving and re-entering the room within a session.
A harvested placement regrows a new variant after REGROW_DELAY_TICKS. The new
variant is drawn with TrySampleBounded at (scatter.regrow domain, placement
scope, ordinal, 0). The per-placement regrow ordinal starts at 1 and advances
exactly once, in the Commit phase of the tick that commits the regrow;
rebuilding, retrying BuildNext or faulting a tick neither advances nor publishes
it. An ordinal that would pass UINT32_MAX fails the regrow with an explicit
count (GetRegrowExhaustions) and leaves the placement harvested; it never wraps.
Tombstones, regrow ordinals and the current harvested/regrown variant enter the replay hash; cosmetic tints do not. Harvest and regrow storage is bounded and allocated when the room is prepared, so the tick, commit and extraction paths allocate nothing. Checkpoint persistence of tombstones and ordinals, weighted variant selection and automatic seed acquisition stay deferred to later randomness milestones.
Level is also directly constructible in code. Prepare and BuildNext use the
same world semantics for compiled definitions and decoded JSON. WriteLevel
produces the legacy v1 interchange JSON into fallible owned storage, preserving the output on
failure. Entity records sort by authored ID. The codec has no filesystem writes;
an editor must provide atomic source-file replacement through its own platform
boundary.
world.h defines the game-owned component roster and observable state. world.cpp has the explicit BeginTick, Intent, Motion, Gameplay, Commit and publication sequence. Add a component pool's initialization, recipe insertion and destruction removal together. Gameplay uses player/enemy/exit capabilities rather than branching on recipe kinds after construction.
RunTick advances exactly 1/60 second (TICKS_PER_SECOND). The application
reads one FoundationTime timestamp per callback and passes it to
TickDriver::Advance. The driver owns the FrameClock:
it accepts at most 250 ms per frame (MAXIMUM_FRAME_DELTA) and runs at most four
ticks (MAXIMUM_CATCH_UP_TICKS). Debt is an integer phase: each frame adds
accepted nanoseconds times the tick rate and a tick consumes PHASE_UNITS_PER_TICK
(10^9), so one second at 60 Hz is exactly 60 ticks however it is split into frames.
Whole ticks beyond the budget are counted by GetDiscardedTicks(), separately from
GetClampedTime(); the sub-tick remainder (GetTickPhase()) carries and sets the
interpolation alpha in [0, 1). The FrameClock is the only clamp. Configure
accepts a TickPolicy (rate, clamp, budget) and returns InvalidConfiguration,
keeping the previous policy, for a zero rate or budget or a phase that could
overflow uint64. A different rate changes real-time speed, not RunTick's
step, so record the policy with replays. A failed tick, including tick-number
exhaustion, publishes no tick and faults the driver. Input edges survive zero-tick frames and apply once
during catch-up. Cancellation takes priority over presses; the bridge waits for
neutral controls before rearming.
Pause/resume, focus changes, queued browser input resets (hide or blur), hidden frames, faults and activation reset debt and the frame baseline, so the next Playing frame is a zero-elapsed baseline and never catches up excluded time. Focus changes preserve pause/fault mode. Diagnostics separate accepted time, time clamped by the frame cap, excluded lifecycle time and clock regressions; for a monotonic sequence the three totals equal the observed span. A regressed sample adds no time and cannot become a baseline below the highest observed reading. A simulation fault retains the previous render frame until restart and never publishes that tick's presentation.
Ownership and limits¶
The reference world reserves 48 entities, 32 commands/outcomes, 64 optional
presentation records, 16 outstanding completion tickets and 193 render records
(MAX_RENDER_DRAWS: the room floor, 48 entities, 16 collision boxes and 128 placements).
Content permits 32 initial entities, 16 static boxes and 128 scatter placements.
These limits leave room for every active entity, static geometry and placement
in the render frame. Queue overflow
and commit preflight return explicit errors; optional presentation overflow counts
drops. Runtime spawning currently accepts guards targeting the existing player.
Queued destruction immediately excludes the entity from systems. The registry and components remain alive until commit. Spawns publish complete bundles after preflight; they appear in that tick's render frame and first simulate on the next tick. Preflight conservatively ignores slots freed by that same batch. Spawn outcomes remain available until the following BeginTick boundary. Combat uses creation order for ties, and trigger contacts distinguish enter, stay and exit. The first entering exit in stable order determines the next level. That required outcome publishes only after the tick succeeds; a fault discards the pending transition together with its presentation records.
A session parses an owned candidate, prepares all pool capacity, builds one recipe
per PollLoad, binds forward references and activates through a nonallocating move
at the frame boundary. Failure, cancellation and supersession preserve the active
world. The two procedural visual keys need no external asset loading or GPU leases.
The old render frame is an owned value; the Renderer and its one quad mesh survive
level changes. Browser loading returns between construction steps.
BeginRequest returns an owned ticket and reserves completion capacity before
admission. SubmitCompletion is an owner-thread mailbox API; a worker would need
its own synchronized transport. It rejects foreign worlds or mismatched tickets,
and BeginTick sorts one bounded result batch by request ID. Superseded and destroyed
entities reject delayed results. Reservations persist until their matching result
or cancellation acknowledgement is consumed. This sample provides no worker pool.
Ludus profiling scopes measure ticks, Intent, Motion, Gameplay, Commit and render
extraction. A 64-record tick trace ring stores consumed input, phase/status and
queue counts.
Records carry tick numbers; the physical ring is not a chronological span after
wrapping. Const pool access exposes health, transforms and AI action phases to a
debugger. Same-build replay tests feed recorded tick inputs through RunTick and
compare canonical hashes that include content and gameplay state while excluding
world IDs, addresses, pool order and rendering history. Hashes include runtime
colliders and outstanding request reservations. GetReplayHash returns zero
outside an idle boundary or while commands/completions await the next tick.
A replay file format and
cross-platform floating-point determinism are future work.
Rendering and validation¶
The player draws each frame through the engine
Renderer on one explicit
RHI device; it keeps no shader of its own. render_items turns the extracted
RenderFrame into scene items. Each of its up to 193 draws becomes one opaque
flat-material item over a shared unit quad, scaled by its half extent and rotated
by its angle. The first draw is the room floor over the level bounds, in
ROOM_FLOOR_COLOR. The draw's position in the frame becomes its depth key, so
later draws cover earlier ones without blending. The gameplay camera sample
becomes a reverse-Z orthographic view through the game's checked camera_view
builder.
FrameRenderer presents through the Renderer's RasterViewport, the same
interactive output the Editor's World viewport uses. It prepares the flat, views
and lighting-output profiles, replaces the snapshot every frame, and composites
to the surface in the same submission. The last drawable view keeps presenting
while a newer view's upload is Pending. Colours are linear, so the window shows
the Renderer's display transform: an 8-bit linear target, per-channel Reinhard
tone mapping at exposure one, then sRGB encoding. Edit and Play show the same
pixels. Outside the room's bounds the Renderer's black clear shows. Collider
boxes are axis-aligned; visual rotation affects drawing. Narrow windows expand the camera height to keep the
authored horizontal bounds visible. The item and view builders have no window,
device or Qt dependency, so an Editor view of a World-demo World can draw the same
geometry from the same extraction.
Native tests cover identity retirement, stale/foreign handles, sparse removal,
compiled and JSON definitions, canonical round trips, candidate ownership,
input/catch-up/pause, commands, combat/contacts, completions and replay. The
scatter tests pin the first room's placement digest, and check presentation
isolation (rendering, pacing, interpolation and extra cosmetic draws leave the
replay hash), content isolation (another room's recipe, a new layer and changed
transform ranges leave existing placements), catalog and manifest rejection, and
load failures that publish nothing. A separate non-sanitized allocator test
injects every candidate storage allocation failure, including the two scatter
allocations, and proves prepared simulation/extraction paths allocate no storage.
ludus_world_render_items_tests compares every sampled pixel centre of the
example room with the retired fullscreen shader's coverage rule, and checks
draw order, depth range, colours and rejected frames. The GPU test
ludus_world_frame_renderer_pixels presents that room on a headless device
through FrameRenderer, then reads back the presented display image. It compares
the image with the same coverage rule passed through an independent restatement
of the display transform. It also checks that rejected and zero-extent frames
keep the last drawable view.
The contributor workflow also checks native ASan/UBSan, format/tidy, header/include boundaries and installed SDK consumption. Browser QA uses the repository's pinned Playwright/PNG prerequisites:
node apps/world_demo/tests/browser-test.mjs out/build/web-emscripten-development/apps/world_demo out/world-browser
Set LUDUS_WORLD_WEBGL=1 to exercise the Auto fallback without WebGPU. The harness
verifies the selected backend and player/guard/exit pixels, compares the page's
scatter placement digest with the pinned native value, captures canvas and
narrow-layout screenshots, and checks movement, pause, step,
restart and focus cancellation. Its Chromium/SwiftShader run is software GPU
acceptance; it does not establish hardware performance or public hosted acceptance.
Fiber scheduling, dependency graphs, render threads, generalized timer queues, prefab reflection, external media preparation and live component patching remain the conditional extensions described in the design docs. They are not prerequisites for this jam baseline.
Recorded checks and remaining limits are in the validation record.
Author a level with the Editor¶
The World workspace
shares this sample's private level codec, not its gameplay state. Native runs
accept --level <path> (at most 64 KiB, regular file, strict v1 validation). It
can be combined with --headless or --frames 3 to qualify a saved World. Invalid
files fail before candidate activation; no source file is rewritten. Without
--level, the built-in first room remains the default. Browser CLI file loading
is not an authoring workflow.
Legacy v1 level JSON remains readable for import and player compatibility. The
Editor converts it to a sibling World file and preserves the original; new
authoring and the shipped rooms use .world.json.