#+TITLE: Deterministic Physics Plan #+DATE: 2026-07-17 #+OPTIONS: toc:2 num:t * Why this document Lockstep multiplayer (the Forts model) requires that every peer's simulation produces bit-identical results from the same inputs. Our simulation is the custom stiff mass-spring solver in =src/game/build_graph.cpp=, not Box2D. Box2D v3 is linked but idle, so its cross-platform determinism does not help us: the code that decides who wins is the mass-spring solver, and it currently calls =sin/cos/atan2= and mixes float widths, so it is not deterministic across builds or platforms. This plan makes the mass-spring solver deterministic. It supersedes the Box2D-based framing under "Deterministic Simulation Design" in =research/tech-stack.org=. * Decision: strict IEEE-754 float, not fixed-point Keep the float solver and make it reproducible by controlling the compiler and owning the transcendental functions. This is the approach Box2D v3 and Factorio take. It needs no rewrite and does not disturb the tuned constants. ** Rejected: fixed-point / integer math Bulletproof by construction, but a full rewrite of the solver and a poor fit for a stiff damped spring system: 14 substeps of accumulation and a large dynamic range between rest and impact forces make precision and overflow hard to manage. Hold it in reserve; adopt only if strict-float fails cross-platform testing. ** Rejected: rollback as a substitute GGPO-style rollback replays inputs through the simulation, so it *requires* determinism rather than providing it. It is a netcode feature layered on top of this plan, not an alternative to it. * Sources of float divergence, and how each is closed 1. FMA contraction (=a*b+c= fused to a single rounding). Disable with =-ffp-contract=off=. 2. =-ffast-math= / reassociation. Never enable on the simulation code. 3. Transcendentals (=sin=, =cos=, =atan2=, =exp=). Not required to be correctly rounded by IEEE-754, so libm differs across OS and libc versions. Replace the ones used in the sim with our own implementations, compiled identically everywhere. Box2D v3's MIT =b2Atan2/b2Sin/b2Cos= are a ready source. =sqrt= is safe to keep: hardware =sqrtss/sqrtsd= is correctly rounded per IEEE-754 and portable. * The plan ** 1. Isolate the tick as a pure step The simulation step must read no wall clock, allocate nothing, touch no global mutable state, and iterate in a fixed order. We are mostly there: fixed 60 Hz, fixed =OVERSAMPLES=, node and edge arrays walked in index order. Audit for two leaks: - any =std::unordered_map= iterated inside the tick (iteration order is unspecified), - any RNG not driven by a seeded PCG shared across peers (fire spread?). =rand()= and hash-map iteration order are the classic desync sources. ** 2. Pin the FP flags on the simulation translation units Scope =-ffp-contract=off=, no =-ffast-math=, and SSE2 (the x86-64 default) to =build_graph.cpp= in =meson.build=, so the rest of the engine is unaffected. We build in the container, so the flags are controlled for every shipped build. ** 3. Own the transcendentals (cross-platform only) Replace =std::sin/cos/atan2= in the solver (strut angles, the 30 degree angle-stress rule) with fixed implementations; keep =sqrt=. This is the step that buys cross-platform determinism, and the only expensive one. ** 4. One float width, no mixing Doubles are as deterministic as floats under strict FP and give headroom for the stiff springs; floats match Box2D and Forts and halve the memory. Pick one and never mix. Given the stiffness, prefer =double= for the solver unless a SIMD or cache reason argues otherwise. Consistency, not width, is what determinism needs. ** 5. Determinism harness (do this first) A test target that runs a fixed scenario (a small fort, a scripted shot) for N ticks and hashes the state: node positions and velocities, edge and fire flags. Run it in CI in the container so a regression trips immediately. The real payoff: run the same hash on a native host build and compare against the container build. Matching hashes mean cross-platform determinism is real, not assumed. * The scoping decision that sets the workload What is the multiplayer matrix? - *Same-binary only* (per-platform builds, never a Linux host against a Windows host in one match): same-binary determinism suffices. Steps 1, 2, 4 and 5 almost certainly clear it, and step 3 can be deferred entirely. - *Cross-platform* (different platform builds in one lockstep match): step 3 (own the transcendentals) becomes mandatory. * Sequencing - Now, while building M4/M5: adopt steps 1, 2, 4 and 5. They are cheap and stop us digging the hole deeper. Build the harness (step 5) first, as the safety net. - Later, when committing to cross-platform matches: flip on step 3. - Keep Box2D idle until projectiles need it; its determinism story then lines up with this one. - Threading: keep the solver single-threaded, or use a deterministic reduction order if it is ever parallelised. Non-deterministic reduction order breaks bit-identity.