---
title: "Explanation: Physics Simulation"
description: "How Gessa runs physics as an integration contract over Rapier 3D plus its own deterministic character controller, why the timestep, mass, transaction, and epoch are shaped the way they are, and which character motor is actually live."
engineVersion: v1.0.234
date: 2026-09-28
license: "(c) Gessa, proprietary. Cite with attribution to https://gessa.ai/docs/. Terms: https://gessa.ai/terms/."
canonical: https://gessa.ai/docs/explanation/physics-simulation/
---

# Explanation: Physics Simulation

Last verified: 2026-09-02 against engine v1.0.232.

{% version engineVersion="v1.0.232" /%}

Gessa does not ship a first-party physics solver. It runs physics as an **integration contract over [Rapier 3D](https://rapier.rs)** (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](backend-authority.md) 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 by `physicsKernelArchitecture.test.ts`.
- **The character lane** is a shared **swept-capsule kinematic character controller** (the KCC), a pure function `stepCharacter` in `runtime-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](../reference/physics-contract.md); the component fields themselves live only in the generated component reference.

{% generated-reference file="docs/spec/generated/component-types.md" label="ECS component reference (physics components)" /%}

## Timestep: 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.

```math
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:

```math
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.

```math
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.

{% warning severity="caution" title="Tuning happens through the room budget, not the backend selection" %}
The physics backend selection object accepts only a `backendId`. The values you actually tune per room (gravity, solver and friction iterations, the substep bound, the CCD substep count) live in the runtime-budget profile and are hashed into `physicsEpochKey`. Changing one restarts the room's physics epoch.
{% /warning %}

## 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:

```math
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:

```math
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:

```math
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 {% component name="CharacterMovementComponent" /%} 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.

{% warning severity="info" title="Reading physics facts" %}
Component field names, defaults, and shapes are generated facts. This page states the model and the invariants; for the exact fields of any physics component, read the generated component reference through the [Physics Contract](../reference/physics-contract.md), never a number typed into prose.
{% /warning %}

## Where to go next

- The contract surface, its limits, and the named proof tests: [Physics Contract](../reference/physics-contract.md).
- Why the server holds authority and the client predicts: [Backend Authority](backend-authority.md).
- What "playable" formally requires and how proofs are structured: [Playability Proofs](playability-proofs.md).
