Physics
← Game Flow | Audio → | Home
Jolt Integration Overview
Jolt Physics v5.5.0 runs at a configurable fraction of the logic rate. Default: 512Hz logic / 8 = 64Hz physics. This ratio is configurable — games needing tighter physics can reduce the divisor. Because the physics step is tied directly to the fixed update rate, it remains deterministic.
Key principle: Jolt owns physics state. The ECS does not push state to Jolt every frame. Jolt's integrator advances bodies internally. The ECS only writes to Jolt on explicit overrides (spawn, teleport, impulse, kinematic target). After each step, only awake bodies are pulled back into SoA write arrays.
PrePhysics → [Override if needed] → Jolt Step → Pull (awake only) → Collision Events → PostPhysics
A scene with 50K physics bodies where 200 are moving pays for 200 pulls, not 50K. Sleeping bodies are the common case.
Physics Tick Timing
Physics entities are pushed into Jolt on (currentFrame % PhysicsDivizor == 0). Transforms are pulled on (currentFrame % PhysicsDivizor == PhysicsDivizor - 1). Between push and pull, the Brain thread acts as a physics worker — it submits Jolt jobs via the JoltJobSystemAdapter and steals from the Jolt queue while waiting. This makes full use of the worker pool without a dedicated physics thread.
Overrides: ECS → Jolt (Event-Driven, Not Per-Frame)
The ECS writes to Jolt bodies only when gameplay logic explicitly demands it:
JPH::BodyInterface& bi = physicsSystem.GetBodyInterfaceNoLock(); // Spawn: create body with initial state from entity data bi.CreateAndAddBody(bodyCreationSettings, JPH::EActivation::Activate); // Teleport: non-physical move bi.SetPositionAndRotation(bodyID, pos, rot, JPH::EActivation::Activate); // Gameplay impulse in PrePhysics bi.AddImpulse(bodyID, JPH::Vec3(ix, iy, iz)); // Kinematic target (animated platforms, elevators) bi.MoveKinematic(bodyID, targetPos, targetRot, fixedDt);
GetBodyInterfaceNoLock() is safe here — the Brain thread is the only writer during the physics window.
Pull: Jolt → ECS (Awake Bodies Only)
After each Jolt step, read results back into SoA write arrays for awake bodies only:
JPH::BodyIDVector activeIDs; physicsSystem.GetActiveBodies(JPH::EBodyType::RigidBody, activeIDs); for (JPH::BodyID bodyID : activeIDs) { uint32_t entityIdx = BodyToEntity[bodyID.GetIndex()]; JPH::RVec3 pos; JPH::Quat rot; bi.GetPositionAndRotation(bodyID, pos, rot); // single call, avoids double lock WriteArray_PosX[entityIdx] = Fixed32::FromFloat(pos.GetX()); WriteArray_PosY[entityIdx] = Fixed32::FromFloat(pos.GetY()); WriteArray_PosZ[entityIdx] = Fixed32::FromFloat(pos.GetZ()); WriteArray_RotQx[entityIdx] = rot.GetX(); // ... // Mark dirty for GPU upload WriteArray_Flags[entityIdx] |= TemporalFlagBits::Dirty; }
Only transforms are pulled. Velocities stay in Jolt. Gameplay code that needs velocity queries Jolt directly during ScalarUpdate via BodyInterface::GetLinearVelocity(bodyID).
Implementation detail: Bodies are stored in AoS. The engine pulls groups of 4 bodies (translation + rotation), SIMD-transposes to SoA format, and writes back to field array positions via the job system.
Collision Events
Jolt fires contact callbacks during Update() from worker threads. These are buffered in thread-local rings:
class ContactListener : public JPH::ContactListener { void OnContactAdded(const JPH::Body& b1, const JPH::Body& b2, ...) override { // Thread-local ring — no locks needed threadLocalContactBuffer.Push({b1.GetID(), b2.GetID(), manifold, ...}); } };
After Pull, Brain drains contact buffers via ProcessContacts(). Contacts resolve BodyID → EntityCacheHandle → EntityHandle and dispatch to callbacks mapped by BodyID.GetIndex().
Two dispatch paths:
- Constructs: direct typed callbacks auto-bound via concept detection (
HasOnHit,HasOnOverlapBegin,HasOnOverlapEnd) duringConstruct<T>::Initialize. Callbacks receivePhysicsOnHitDataorPhysicsOverlapData. - Entities (designed, not yet implemented):
ContactSystem<T>— one CRTP instance per entity type. Pre-hydratedEntityViewrepoints its FieldProxy cursor to the contacted entity's cache index duringProcessContacts. No per-entity allocation, no dynamic View hydration.
Body Lifecycle
Entity spawn (with physics component) → CreateAndAddBody(), store BodyID↔EntityID mapping Entity destroy → DestroyBody() Entity deactivation (Active=0) → DeactivateBody() (skipped in broadphase) Entity reactivation → ActivateBody()
CreateAndAddBody() is a single call — avoids the create-then-add pattern that requires an extra lock acquisition.
JoltCharacter wraps JPH::CharacterVirtual for Construct-driven movement. Completely independent of CJoltBody — no Jolt body is created in the ECS.
class Player : public Construct<Player> { ConstructView<EPlayer> Body; JoltCharacter CharacterController; void InitializeViews() { Body.Initialize(this); CharacterController.Initialize( GetWorld()->GetPhysics()->GetPhysicsSystem(), JPH::RVec3(0, 5, 0), // spawn position 0.3f, // capsule radius 0.7f); // capsule half-height } void PhysicsStep(SimFloat dt) { CharacterController.Update( desiredVelocity, gravity, dt, *GetWorld()->GetPhysics()->GetTempAllocator()); JPH::RVec3 pos = CharacterController.GetPosition(); Body.Transform.PosX = SimFloat::FromFloat(pos.GetX()); Body.Transform.PosY = SimFloat::FromFloat(pos.GetY()); Body.Transform.PosZ = SimFloat::FromFloat(pos.GetZ()); } };
JoltPhysics::GetTempAllocator() exposes the Jolt temp allocator needed for ExtendedUpdate. JoltLayers.h defines shared layer constants (Static, Dynamic) used by both JoltPhysics and JoltCharacter.
Constraint System
A constraint is a relation between two entities: which bodies, which anchors, which type. Parenting and attachment are the simplest case of it, and the same rows are what the Trinyx physics solver will read — so this is the one mechanism for "X follows Y", from a weapon in a hand to a ragdoll joint.
Status: Designed, not yet implemented. Revised 2026-10; supersedes the flat-pool description in
docs/legacy/ARCHITECTURE.md.
Decisions
| # | Decision | Why |
|---|---|---|
| 1 | Constraints are entities in the LOGIC partition, with their state in Temporal fields | Attaching and detaching is gameplay state: a predicted pickup that is rolled back must take its attachment with it, and it must replicate. As entities, constraints get rollback, replication, and despawn from the existing paths — no second mechanism |
| 2 | Bodies are referenced by EntityHandle, never by slab index | Defrag and slot reuse move entities; a stale index silently attaches to the wrong entity |
| 3 | The sim resolves rigid attachments, writing the child's world transform into the slab each fixed step | Gameplay, hit registration (a muzzle position), attached trigger volumes, and replication all need the child's real position in the sim, not only on screen |
| 4 | The render thread re-resolves rigid children from the interpolated parent | A child interpolated on its own lags its parent by a fraction of a frame and visibly jitters against it. Render's job here is only that: keep the child locked to the parent it is drawn with |
| 5 | One resolve list, sorted by depth, evaluated in a single pass | Replaces "iterate once per depth level, cap at 4 passes" |
| 6 | This is the basis of the Trinyx solver | Non-rigid types are solved from the same rows; replacing Jolt replaces the solver loop, not the data |
Data
struct CConstraint // on a constraint entity (LOGIC partition); Temporal: rolls back and replicates { EntityHandle BodyA; // the constrained body (the child, for attachment); invalid = static world anchor EntityHandle BodyB; // what it is constrained to (the parent) ConstraintType Type; uint16_t BoneA, BoneB; // UINT16_MAX = entity root pivot Fixed32 AnchorA[3]; // local-space anchor on BodyA Fixed32 AnchorB[3]; Fixed32 LimitMin[3]; // DOF limits (translation mm or angle units) Fixed32 LimitMax[3]; Fixed32 Stiffness; Fixed32 Damping; }; enum class ConstraintType : uint8_t { Rigid, // All 6 DOF locked — rigid transform inheritance (weapon to hand) RigidWithScale, // Rigid + scale inherited PositionOnly, // Translation locked, rotation free Hinge, // 1 rotational DOF free (door, axle) BallSocket, // 3 rotational DOF free, translation locked (shoulder, hip) Prismatic, // 1 translational DOF free, rotation locked (piston, slider) Distance, // Fixed distance, all rotation free (chain link) Spring, // Distance constraint with stiffness/damping };
Several constraints may join the same two bodies (a door: hinge + spring), and a world-anchored constraint is one with an invalid BodyB. An entity has at most one rigid constraint as BodyA — one parent.
The Resolve List
The solver and the attachment pass want every field of a constraint together and want constraints in dependency order. That is a derived cache, rebuilt when a constraint is created, destroyed, changed, or when an entity it names moves in the slab — never the authority, never rolled back or replicated:
ResolveList (flat AoS, sorted by depth, then by handle — rebuilt only when dirty): slab index of BodyA, slab index of BodyB, type, bones, anchors, limits ByBodyA[entity] -> its rigid constraint (none = physics root) Children[entity] -> the constraints that name it as BodyB (attachment walks, despawn, the editor outliner)
- Depth is the existing 4-bit attachment depth in the entity flags (
depthMask): a root is 0, a rigid child is its parent's depth + 1. It is derived, updated for the affected subtree on attach and detach. Maximum depth 15; an attach that would exceed it or form a cycle is rejected. - Order is a 16-bucket counting sort on depth. One pass over the list then resolves every parent before its children. Rows at the same depth are independent, so each level splits into jobs.
- Determinism: the secondary sort key is the handle, so two machines build the same list from the same constraint entities; after a rollback the list is rebuilt from the restored fields.
- Cost follows the number of constrained entities. Entities with no constraint are never visited, and the wide sweeps over the slab are unchanged.
Rigid Attachment
Type=Rigid is the degenerate constraint — transform inheritance:
World[BodyA] = World[BodyB] (through BoneB if set) ⊗ LocalOffset(AnchorB → AnchorA)
- Sim: resolved once per fixed step over the resolve list, after physics results are pulled, writing the child's transform into the slab like any other entity data.
- Render: resolved again from the interpolated parent before GPU upload (the same list, uploaded as rows), so the child is drawn exactly where its parent is drawn.
- Physics: only roots are simulated. An entity that is
BodyAof a rigid constraint gets no body of its own; it moves with its root. Entities that need their own physics and follow a parent (ragdoll limbs, rope segments) use a non-rigid type and are solved by the physics solver, not by transform copy.
What This Replaces
Entity-to-entity ordering in general. Entities carry no bespoke logic, so "X must update before Y" reduces to three cases, and only the last needs same-frame ordering:
| Dependency | Mechanism |
|---|---|
| Logic order (a base thinks before its barrel) | Constructs: TickGroup and OrderWithinGroup |
| Soft read (an AI reads its target's position) | The previous published frame — one fixed step of lag, no ordering |
| Derived state (attachment, sockets) | The depth-sorted resolve list |
Open Questions
- Where the sim resolve runs. After the PostPhysics sweep and before the
ScalarPostPhysicsbatch lets Constructs read attached positions (a muzzle); a Construct that then moves a parent leaves its children one step behind unless the dirtied subtrees are resolved again before publish. - Replication of rigid children. A rigid child's transform is derivable from its constraint and its parent, so the constraint should replicate and the child's transform should not.
- Despawn policy. When a parent is destroyed: children detach where they are, or are destroyed with it — per constraint, with which default?
- Relations that are not physics. The editor outliner also needs Construct ownership and prefab membership. Whether those are further constraint types or a separate relation kind read through the same
Childrenlookup.
Note on naming: "Jolt constraints" (Jolt's internal joint system) are a different concept. When Jolt is replaced with a custom solver, Jolt joints disappear but constraint entities remain — the custom solver reads them.
Rollback and Jolt
For rollback, Jolt uses SaveState/RestoreState via StateRecorderImpl. Per-frame snapshots (~7KB for 56 bodies) are stored in a ring buffer after each PullActiveTransforms.
When rolling back to frame N: snap to the nearest Jolt execution frame at or before N (e.g., rollback to frame 100 → restore from frame 96 at 8:1 ratio), restore Jolt state, resimulate from there. At most 7 frames of physics approximation in the worst case — acceptable for competitive multiplayer.
Rebuild-from-slab was tested and rejected. Restoring only entity positions loses the contact cache, solver warmstarting, and sleep states. The solver reinitializes from scratch and diverges from the original timeline. Snapshot restore is the only correct approach for deterministic resim.
Determinism status (2026-03-29): byte-perfect across 5-12 frame rollbacks with 100k entities + 56 physics bodies.
See Rollback Netcode for the full rollback architecture.
Networked Physics Without Rollback
With rollback disabled, server corrections to physics-driven entities are applied as Jolt teleport overrides, with no prediction replay. Physics-driven entities an Owner controls will jitter as corrections fight the solver. This is a known limitation, not a bug to patch around Jolt — see Networking Without Rollback.
Long-Term: Trinyx Physics Solver
Jolt is a temporary backend. The long-term direction is a Trinyx physics solver built on the engine's own data layout: solver state lives in slab fields rather than a separate physics world, so snapshot, rollback, partial re-simulation of predicted entities, and GPU upload all work on the same data. Full client-side prediction for physics-driven entities waits on this solver. The constraint systemconstraint system is its basis: it is solver-agnostic, so it carries over unchanged.