Explanation: Physics Simulation
Last verified: 2026-09-02 against engine v1.0.232.
engine v1.0.232Gessa does not ship a first-party physics solver. It runs physics as an integration contract over Rapier 3D (pinned in server/package.json) for the rigid-body world, plus its own deterministic character controller for player movement. Rapier does the rigid-body solve; Gessa owns the contract around it: the timestep, the mass model, the contact and force rules, the tick transaction, the epoch, and the shared character controller. This page explains why those contracts are shaped the way they are, and it links down to the generated reference for the component surface rather than restating it.
Two framings from Backend Authority carry straight into physics: the server is authoritative, and the client is a projection that must be able to predict the same result the server will commit. Both framings force the design choices below.
Two lanes, one tick
Physics runs in two lanes behind one room tick.
- The rigid-body lane is server-authoritative and backed by Rapier (WASM). A backend-agnostic kernel owns create, step, query, and dispose. Every native Rapier call stays inside a small adapter family, and there is exactly one native integration step owner: Rapier's
world.step(). That single-owner boundary is not a convention; it is asserted byphysicsKernelArchitecture.test.ts. - The character lane is a shared swept-capsule kinematic character controller (the KCC), a pure function
stepCharacterinruntime-shared. The same function is called by client prediction and by server authority against the same replicated static-collision snapshot, so the two are bit-identical by construction.
Rapier owns dynamic, kinematic, and static bodies, colliders, joints, force commands, and spatial queries. The KCC owns player-character movement: ground, slopes, and jumping. The reference for the contract surface of both lanes is the Physics Contract; the component fields themselves live only in the generated component reference.
ReferenceECS component reference (physics components)Resolved signature, schema and exampleTimestep: accuracy decoupled from tick rate
A room can tick slowly (to save server cost), but physics must not integrate gravity in one large jump or let a fast body tunnel through a wall between ticks. So the adapter substeps a fixed dt under a bound that is independent of the tick rate. The bound is RUNTIME_PHYSICS_POLICY.maxSubstepDtSeconds, whose default is 1/50 s; the tick rate default is RUNTIME_LOAD_TEST_POLICY.defaultTickRateHz, which is 20 Hz.
tickInterval = 1 / max(1, tickRateHz)
substepsPerTick = max(1, ceil(tickInterval / maxSubstepDt))
dt = tickInterval / substepsPerTick
At the defaults this resolves to three substeps of about a sixtieth of a second:
substepsPerTick = ceil( (1/20) / (1/50) ) = ceil(2.5) = 3
dt = 0.05 / 3 ~= 0.0166667 s
The floors matter and are easy to miss: maxSubstepDt is floored at 1 ms, and both substepsPerTick and the CCD substep count are floored at 1, so a degenerate configuration can never produce a zero or negative step. The derivation lives in the adapter's substep loop and is frozen, together with the rest of per-tick behavior, by the physics golden corpus.
Mass is a scalar, not a shape integral
Gessa's mass model is deliberately simple and shape-blind. A body's mass is explicit (default 1). Its angular inertia is isotropic and equal to the mass scalar, (m, m, m), set by angularInertiaForMass rather than derived from the collider geometry. Collider density is forced to 0 by setDensity(0), so geometry contributes no mass on its own.
mass = authored mass (default 1)
angularInertia = (m, m, m) # isotropic; NOT the shape's inertia tensor
colliderDensity = 0 # geometry adds no mass
This is a real limit, not an oversight: a long plank and a compact cube of the same mass resist rotation identically. It keeps authored behavior predictable and keeps the mass model inside one small helper instead of scattered across shape handlers. Per-body gravityScale (default 1) and the per-room gravity vector RUNTIME_DEFAULT_RUNTIME_BUDGET.physicsGravity (default (0, -9.81, 0)) complete the acceleration inputs.
The tick transaction and the epoch
Because the server only commits a tick after its durable authority commit succeeds, the physics step cannot advance the durable coordinate ahead of that commit. The kernel splits the step into three phases:
- prepare advances native Rapier state but not the committed coordinate;
- commit advances the coordinate, and only after the host's durable commit;
- abort disposes the native world so the next tick cold-rebuilds from durable authored state.
Ticks strictly increase, and the invariant (prepared step does not advance the coordinate; commit is monotonic; abort cold-rebuilds) is proven by physicsTransaction.test.ts.
Backend and configuration are frozen per epoch. The physicsEpochKey hashes the backend id, the tick rate, the gravity vector, and the per-room physics budget fields (substep bound, CCD substeps, and the three solver-iteration counts). If any hashed field changes, the world is disposed and rebuilt as an admitted restart, never silently reconfigured mid-flight. That epoch key is also why the per-room budget is a genuine tuning surface: a blueprint can override gravity and the solver iterations through its runtime-budget profile, and the epoch key makes that change an observable restart rather than a quiet drift.
The character controller
The KCC integrates one intent (a horizontal move, a lift request, optional buoyancy) into one new pose. All of its tuning arrives as parameters derived from authored components by deriveMovementParams, over the named defaults in RUNTIME_KCC_DEFAULTS; nothing in the controller is a hidden gameplay constant.
Horizontal velocity snaps to zero on zero input and otherwise approaches the target at acceleration * dt. The zero-input case is an instant stop, not a decelerating glide:
if |move| <= EPSILON:
v_x = v_z = 0 # instant stop
else:
v_target = normalize(move) * maxSpeed
v = approach(v, v_target, acceleration * dt)
Vertical velocity is one shared semi-implicit Euler integrator (integrateVerticalVelocity), with a floor-rest fast path so a resting body does not jitter, and a post-move clamp so a body that just landed does not keep a downward velocity:
preserveFloorRest = onFloor and (not lift) and v_y <= 0 and buoyancy <= 0
v_y = 0 if preserveFloorRest # fast path, bypasses Euler
v_y = liftImpulse if lift and onFloor
v_y = v_y + (buoyancy - gravity) * dt otherwise
# after collide-and-slide and the floor probe:
if onFloor and v_y < 0: v_y = 0
Note the default: deriveMovementParams reads gravity as a non-negative field defaulting to 0. A character with no authored gravity does not fall under the KCC; vertical motion is opt-in through authoring.
Position uses collide-and-slide against the typed collision query, over at most MAX_SLIDE_ITERATIONS (4) iterations, keeping a CONTACT_SKIN (1e-3) separation from every surface so a flush capsule never degenerates into a "started inside" sweep:
remaining = v * dt
repeat up to MAX_SLIDE_ITERATIONS:
hit = sweep(position, position + remaining, radius, halfHeight)
if no hit: position += remaining; stop
position += remaining * hit.time + hit.normal * CONTACT_SKIN
remaining = removeAlongNormal(remaining * (1 - hit.time), hit.normal)
v = removeAlongNormal(v, hit.normal)
Ground is detected either by an upward-normal contact during the slide, or, only when the body is resting or descending, by a short downward probe that snaps a resting capsule to its support. An ascending body must leave the floor, so the probe is skipped while vertical velocity is positive; otherwise a small jump would be silently cancelled. The collision query models the capsule as an axis-aligned box (radius on the horizontal axes, radius plus half-height on the vertical axis); box solids resolve by the slab method and static mesh triangles by an exact swept separating-axis time-of-impact, so a box and a coplanar pair of triangles push the character identically.
Client prediction and server authority run this exact function over contract-derived params, which is why runtimeNetcodeKccParity.test.ts can assert bit-identical {position, velocity, onFloor} over randomized input tapes.
Which character motor is actually live
The controller source carries a comment describing two character motors: an older analytic flat-plane clamp, applyVerticalDynamics, described in-code as "live today on both client and server," and the swept-capsule KCC, stepCharacter, said to subsume it later. A call-graph trace contradicts that comment, and the trace is the authority here, not the comment.
stepCharacter is the live character motor on both paths. The server-authority path reaches it through roomActor.ts (gated by the default-on RUNTIME_NETCODE_KCC_AUTHORITY_ENABLED), KccMovementController.step, and createKccAuthority. The client-prediction path reaches it through the shared CharacterPredictor behind OwnedEntityPredictor. The flat-plane applyVerticalDynamics has no call site anywhere in the shipped tree (server, packages, or the client): a search finds it only in two code comments, one of which is the stale "live today" line. Both motors would share the one vertical-velocity integrator, but only the KCC's position and ground model is wired.
What the enableKcc flag gates is not a choice between two live motors. It is the single governance predicate ownsKccGovernance, which decides which transform-owned characters the KCC steps, and it returns true only when a componentCharacterMovementComponent sets enableKcc to true (default false). The server tick and the client predictor resolve governance through that same predicate, so they always govern the identical entity set. That shared predicate, not a per-side heuristic, is what keeps prediction and authority in agreement.
Where to go next
- The contract surface, its limits, and the named proof tests: Physics Contract.
- Why the server holds authority and the client predicts: Backend Authority.
- What "playable" formally requires and how proofs are structured: Playability Proofs.