World
This file covers the world tier’s SushiLoop layers — the per-tick snapshot buffer rollback is built on, the loopback network reconciliation built on that, and the host-side loop core a standalone game is authored against — and the frame body both host applications compose a frame through.
1. SushiLoop Snapshot: rollback (M3)
engine/world/loop/include/SushiEngine/loop/rollback.hpp’s RollbackBuffer is SushiLoop’s
Snapshot layer: a fixed-capacity ring of per-tick world snapshots, keyed directly by Chunk*.
capture(world, tick) walks every archetype (World::query with the empty signature matches all
of them, since signature_contains treats an empty need as a subset of everything) and every
chunk, byte-copying each column’s live rows (count() * column_size, not the chunk’s full
capacity).
restore(tick) writes those bytes straight back into the same chunks and restores each chunk’s
live count via Chunk::restore_count — a rollback-only accessor that bypasses
allocate_row/remove_row’s entity-directory bookkeeping entirely, which is safe only because
of this class’s central constraint: no entity may spawn or be destroyed, and no chunk/archetype
may be created, between a capture and its matching restore. A Chunk* is stable identity only
as long as the chunk it names keeps existing and keeps holding the same entities in the same
rows; RollbackBuffer does not (yet) defend against a violation, which is why the constraint is
documented as a hard scope boundary rather than handled generically.
This also means capture is deliberately not the “only what changed” delta the design note
(docs/archive/design/SUSHILOOP.md) ultimately wants — every live chunk is copied in full every
tick. Real per-write dirty tracking needs something upstream (Schedule, CommandBuffer) to mark a
chunk touched, which nothing does yet; getting the capture/restore/replay invariant right first, on
the whole-chunk case, is this milestone’s scope. RollbackBuffer also does not decide when to
roll back or replay ticks forward afterward — that orchestration (a game loop, or later the Net
layer’s reconciliation) is the caller’s job, the same way Chunk does not know what a system is.
1.1. SushiLoop Net: loopback reconciliation (M4)
engine/world/loop/include/SushiEngine/loop/net.hpp (namespace Loop::Net) is SushiLoop’s
network layer, scoped deliberately narrow: loopback only. LoopbackChannel<Command> is an
in-process, synchronous stand-in for a client-to-server command link —
client_send(tick, command) records the client’s own prediction into an InputHistory<Command>
and queues it; server_process(corrector) drains the queue and returns one Ack per tick, where
corrector (a caller-supplied callable) stands in for whatever a real server would compute
authoritatively. There are no sockets, no threads, no serialization, and no general P2P/lockstep
protocol here — those are out of scope for this milestone, the same way M3 scoped out per-write
dirty tracking.
Reconciliation (Net::reconcile) is built entirely on the existing M3 machinery, not a parallel
mechanism: for every ack that disagrees with what the client predicted for that tick, it corrects
the client’s InputHistory in place (a new InputHistory::correct, an overwrite rather than
record’s append-only insert), tracks the earliest disagreeing tick, and — if any correction
happened — calls RollbackBuffer::restore on that earliest tick and replays every tick from
there through the caller’s current tick, re-applying the (now-corrected) history via a
caller-supplied apply function. Ticks the client already predicted correctly are simply
re-simulated identically; only the mispredicted ticks change on replay. This is the direct
generalization of the snapshot invariant (rollback+replay
reproduces an uninterrupted run) to the case where the input itself changes underneath the
replay, not just the tick range.
Net::make_network_id(client_id, tick, spawn_sequence) is the deterministic-id half M4 needs: an
entity spawned mid-simulation must get the same id on server and client without a matching
round-trip, so the id is derived from facts both sides already agree on from the numbered command
stream itself — which client is spawning, which tick, and that spawn’s index among the client’s
spawns that tick — packed into one 64-bit value, rather than assigned by whichever side’s spawn
call happens to run first.
Integration_NetReconciliation (tests/integration/test_net_reconciliation.cpp) proves the
milestone’s key invariant with a toy Scalar command: a client that mispredicts several ticks and
later reconciles against the server’s authoritative commands converges to exactly what an
uninterrupted server-only simulation would have produced. Unit_NetworkId
(tests/unit/test_network_id.cpp) covers make_network_id’s collision behaviour directly. Both are
kept as narrow, isolated proofs of engine/world/loop/include/SushiEngine/loop/net.hpp’s mechanics.
samples/networking/net_demo.cpp and Integration_NetClientServer
(tests/integration/test_net_client_server.cpp) wire the same machinery into a live client/server
harness driven by a real gameplay command instead of the toy one. PlayerCommand — two movement
axes, applied to a player entity’s Position — is the Command SushiLoop’s real command stream now
uses; it is deliberately game-side (defined at the point of use, not in engine/world/loop/),
matching engine/world/loop/include/SushiEngine/loop/input.hpp’s stance that the command type is
left to the game.
“Client” and “server” are modelled as two logical roles in one process, each owning its own
ecs::World, which is the honest shape of a loopback-only milestone — there is no second process
or thread to model them as. The harness proves the full chain live: per-tick prediction into
InputHistory, batched LoopbackChannel::server_process acks, Net::reconcile rolling back and
replaying on misprediction, and convergence to an uninterrupted authoritative-only baseline world
— then, in a second phase kept strictly outside the ticks captured by RollbackBuffer,
make_network_id proves its agreement against an actual independent spawn on both the client’s
and the server’s World, with no matching round trip.
That second phase’s placement is deliberate, not incidental: RollbackBuffer still cannot
survive a spawn or destroy inside a tick range it might roll back and replay (see
the hard constraint), and this milestone does not attempt to
lift that — the demo and test sidestep it by never spawning within the reconciled window, rather
than solving rebasing across a structural change. Real transport (sockets), and rebasing
RollbackBuffer across a network-driven structural change, remain later work; so does wiring any
of this into the editor’s Play mode, which steps RuntimeSimulation through
the frame body with no client/server split.
2. SushiLoop core
docs/archive/design/SUSHILOOP.md is the design note; this section is the pointer from architecture
to it. engine/world/loop/ holds the first, purely host-side layer of SushiLoop — plain C++, no
runtime or SYCL involvement — that the fixed-tick sim/net/snapshot work (M1 onward) builds on:
-
FixedTimestepClock(engine/world/loop/include/SushiEngine/loop/fixed_timestep.hpp) turns real elapsed time into a whole number of fixed simulation steps plus a leftover interpolation fraction. It never reads the wall clock itself — the host accumulates real delta time into it — which keeps the number of ticks a run performs independent of timing jitter, a determinism precondition, up to the catch-up bound below.RuntimeSimulation(see the XPBD section) owns one of these and is its first consumer: each host measures real frame time and hands it toFrame::compose, which callsISimulation::tick(real_delta_seconds), keeping the one wall-clock read outsideengine/world/simulation/entirely. -
RNGState(engine/foundation/core/include/SushiEngine/core/random_number_generator.hpp) is a trivially copyable xorshift128+ generator, storable as an ECS component so seeded randomness travels with the world through snapshots and rollback instead of living in a hidden global. -
InputHistory<Command>(engine/world/loop/include/SushiEngine/loop/input.hpp) is the per-tick, numbered command buffer shape that networked input capture and rollback replay (M3/M4) will read and write; the command type itself is left to the game. -
Loop::App<Command>(engine/world/loop/include/SushiEngine/loop/app.hpp) is the authoring API that ties the above together into the settled surface a game is written against. It is the composition root: it owns theSushiRuntime, builds theExecution::Contextover it (handed out byexecution()for a subsystem that needs its own graph or columns), and owns theWorldandSchedule.It drives one fixed-step deterministic loop: each
step_once()captures the tick’s command intoInputHistory(and aRollbackBuffersnapshot when enabled), applies it via the game’son_command, runs theSchedule, and applies theCommandBufferbarrier. Systems are declared ergonomically withapp.system<Read<A>, Write<B>>("name").each(fn), a thin wrapper overSchedule::each(viaSystemBuilder).The loop is always multiplayer-ready: the command stream is numbered every tick regardless of network state, and the network is reached only through
Loop::Net::INetworkTransport<Command>(see the net layer), soconnect()-ing a transport turns a single-player game networked with no change to its systems. This is the point at which the SushiLoop core layers are wired intoSchedule/World— the standalone-game host, distinct fromengine/world/simulation/’s editor-facingISimulation(see the XPBD section).
SUSHIENGINE_DETERMINISTIC_FP (cmake/ProjectOptions.cmake, default ON) disables fast-math and
FP contraction on the SushiEngine INTERFACE target, closing off two ways a build could make the
same floating-point expression evaluate differently between runs.
2.1. The catch-up bound
This section is the one statement of the bound; the chapters that name a clock link here rather than restate it.
FixedTimestepClock::accumulate opens a per-frame budget of max_substeps steps, defaulting to
DEFAULT_MAX_SUBSTEPS = 8. Inside one frame, consume_step() returns true once per whole fixed
step in the accumulator until the budget is spent; a backlog larger than the budget ends the
frame and the remainder is dropped, not carried. The accumulator is zeroed rather than left
holding a sub-step remainder, so the run resumes on a fresh phase.
Carrying the backlog is what the bound rejects. Replaying it spends the following frames catching up on time already lost, turning one hitch into a run of them, and it hands whatever integrates per step a burst long enough to run a ramped control (a vehicle throttle, say) to its end inside one frame. A breakpoint or an alt-tab is exactly this case.
The cost is a real, bounded weakening of the determinism precondition above. The tick count is no
longer a pure function of total elapsed time: the same second of simulated time fed as one 1000 ms
frame and as sixty 16 ms frames now yields different tick counts, because only the first hits the
bound. It stays a pure function of elapsed time for any feed whose per-frame backlog stays inside
the budget, which is every frame of a run that is keeping up — the bound changes behaviour only
where the alternative was a burst. Unit_FixedTimestep pins both halves:
StepCountIsIndependentOfChunking over feeds that stay inside the budget, and
BoundsOneFramesCatchUpAndDropsTheRest over one that does not.
VFX::Driver::consume (engine/domain/vfx/include/SushiEngine/vfx/driver/emitter_driver.hpp)
bounds itself the same way and with the same default of 8, and additionally reports the drop to
its caller through Driver::Substeps::ceiling_reached. It is a separate accumulator, not this
one. No other nested clock exists in the tree today: the regional weather grid that used to keep
one was retired with regional_weather_grid.hpp (see
the atmosphere chapter), and Loop::App owns a FixedTimestepClock of
its own, which neither host uses.
3. The frame body
engine/world/frame/ is the module both host applications are built around.
Frame::compose(ISimulation&, FrameInput&, Render::FrameView&, ComposeOutputs&)
(engine/world/frame/include/SushiEngine/frame/compose.hpp) is one host frame, and it is callable
with no host at all — which is the point, because a frame body that lives inside an executable
behind ImGui is reachable from no test, and two such bodies diverge in ways only a reader comparing
them line by line can see. The module’s own README.md holds
its dependency and test facts; this section is the shape. It sits above simulation rather than
inside it because the two answer different questions: simulation owns the world, and this module
derives one frame from it.
compose runs eight steps in order:
-
Apply the frame’s simulation settings, bind
FrameInput::tick_inputas the per-tick input source, and discard accumulated input on the transition from a non-ticking frame to a ticking one (see the input chapter). -
Tick by
TickPolicy:Playingpasses the host’s real delta,Steppasses exactly one fixed step,Pausedticks nothing. -
Resolve the assets bound to the world since last frame — materials behind the asset library’s revision counter, mesh filters every frame because no counter reports what they wait for.
-
Acquire the simulation’s
RenderSceneextract. -
Convert every channel of the extract into the view’s own owned channel. Every instance’s model and the selected camera’s pose are blended here by
RenderScene::interpolation(lerp/slerpagainst the entity’s or the camera’s previous fixed-step pose, current scale kept) — render interpolation, up to one step of display latency, never a guess ahead of the simulation; seeengine/world/simulation/README.md’s own “Render interpolation” section for where the previous pose comes from. -
Select the camera: the requested display’s, falling back to the first remaining display when the requested one has been deleted, writing the fallback back through
FrameInput::display. -
Derive the environment (
drive_environment,engine/world/frame/include/SushiEngine/frame/environment_drive.hpp): the quality budget, the epoch, the observer derived from the anchor, the sky at that observer’s real ground altitude, the dominant-body rebase, and the nearest body. -
Carry the render settings and the extract’s
generationthrough.
What goes on which output. The two output parameters have two audiences, and the rule is a
single line: Render::FrameView holds what the renderer reads, and everything else a step
produces for a host goes on Frame::ComposeOutputs
(engine/world/frame/include/SushiEngine/frame/compose_outputs.hpp) — the nearest-body surface, the
sky-days readout, the ridden body, the rotation a scene-frame rebase applied to the axes — which a
host composes onto its camera’s facing — the display list, and the render settings the host
must apply itself. NearestBody lives on the outputs rather than near FrameView because a
signed distance to a planet’s surface is not a render concept and a Render:: type could not
honestly name it.
FrameView is also held to a second rule of its own — it never gains a channel
Simulation::RenderScene does not have — which
tools/layering/check_frame_view_parity.py
enforces.
Both hosts derive their input through one header.
engine/world/frame/include/SushiEngine/frame/host_frame_input.hpp declares PlayerFrameState and
EditorFrameState and the two fills over them, player_frame_input() and editor_frame_input(),
which each host calls immediately before compose. The fills live in the engine rather than in the
hosts because a fill inside an executable is reachable from no test: a parity test can only
transcribe it by hand, and a transcription that drops a field both hosts set still passes. The
gather that fills a PlayerFrameState or an EditorFrameState stays in the host, beside the
window and panel state only the host can read, so what remains untested is mechanical field copying
rather than the derivation.
compose deliberately owns no submission: it fills a FrameView and never calls
ISceneView::render, because the editor has three viewports and the player has one swapchain.
Presentation, input devices, audio, and UI layout resolution stay the host’s.
Two instruments hold the seam. Integration_FrameHostParity
(tests/integration/test_frame_host_parity.cpp) calls each host’s own fill and checks that the
two compose the same frame over one scene — the divergence class that is invisible to a static
check, since both hosts call the same function with the same signature.
tools/layering/check_host_frame_ownership.py covers the other direction: it fails a host that
calls one of the engine functions compose, drive_environment or load_scene_into owns, that
assembles a Render::CameraView itself instead of calling Frame::camera_view_from_state, or
that has stopped calling Frame::compose and Frame::load_scene_into at all. Both are described
in the checker table.

