Loading…
gathering yarn balls…
Cat Game Research
Frontier
Dev-log transmissions from the expedition

Building a Physics Engine From Scratch in C++20: A Step-by-Step AAA Blueprint

A complete, expanded blueprint for building a rigid-body physics engine from scratch in C++20, drawn from the V Engine's production physics system and our formally proven Level-2 and Level-3 manifold physics papers. Thirteen pipeline stages, new manifold and broadphase diagrams, a Level-3 demo video, and a post-mortem of what a 7-day trial-and-error engine gets wrong.

There are two ways to build a physics engine. The first is the one you see in most weekend tutorials: start with a circle and a velocity vector, add gravity, then add a second circle, then discover that circles overlap, then add a dot product, then spend five days trying to stop a stack of boxes from vibrating itself into the floor. Zanzlanz's "Coding a Physics Engine from scratch!" is an honest record of exactly that journey. It is valuable as a diary of learning and a testament to persistence, but it is also a catalog of anti-patterns: energy injection from constraint drift, no fixed timestep, no warm-starting, no broadphase, no formal solver, and a heavy reliance on trial and error.

The second way is to build the pipeline as a composition of well-understood operators. This post is that blueprint. It is based on the V Engine's production physics system and on our Level-2 manifold physics paper and Level-3 deterministic accumulator paper. The goal is a AAA-grade rigid-body engine in C++20: stable stacks, deterministic behavior, parallel where safe, and mathematically grounded where it matters.

"A physics engine is not a collection of collision hacks held together by dot products. It is a fixed-timestep operator on an extended state that includes both bodies and a persistent contact cache."

Cat Game Research · Level-2 Manifold Physics · 2025
Level-3 vs Level-4
This post is grounded in the V Engine's Level-3 physics (manifold cache + warm-started PGS + GPU integration + early-out solver). The video below shows that pipeline in action. The engine is now moving toward Level-4, which adds convex dynamic mesh colliders and richer gameplay physics. Level-4 is in active development in engine/systems/physics/ and in the gym_physics level, but its formal paper and proofs are not yet published.

What this post is and is not

This is a step-by-step implementation blueprint, not a complete source dump. It assumes you are comfortable with C++20, glm for vector math, and the idea that a physics system is one part of a larger Entity Component System. Every stage below has a corresponding implementation in engine/systems/physics/ and a formal justification in our papers. The math is shown in inline diagrams; the pseudocode is shown in plain blocks; the tuning parameters are shown in a reference table.

This post is also an anti-design study. Where a naive approach would fail, we point to the Zanzlanz video as a real example of how that failure looks. The critique is not of the author — the author is learning in public, which is admirable — but of the design trajectory that the video represents. If you are building an engine for a shipped game, you want the trajectory in this post.

10
Pipeline Stages
4
Max Contacts Per Manifold
∞
Substep Divisor
2
Formal Proof Papers

The whole pipeline in one sentence

The fixed-timestep update is a composed operator on an extended state (bodies, manifold_cache):

Integrate→BuildGrid→GeneratePairs→Narrowphase→UpdateCache→BuildIslands→WarmStart→Solve→RightingCorrections→AxisConstraints→PruneManifolds→UpdateSleep

In our Level-2 paper this is written as a single composite map:

ΦΔt = Πsleep ∘ 𝒮 ∘ 𝒲 ∘ 𝒰 ∘ 𝒩 ∘ 𝒫 ∘ 𝒢 ∘ ℐΔt

Composite fixed-substep operator (Level-2 Manifold Physics, §Unified Operator Formulation)

If you run that operator s times per frame with timestep Δt/s, you get the frame-level operator. That is the architecture. Everything else is engineering inside the stages.

Step 1: The rigid body as a component, not a class

Before writing integration, decide the data layout. In a modern C++20 engine using an ECS, a body is a set of plain components:

engine/components/physics/rigidbody_component.hpp
enum class BodyType { Dynamic, Static, Kinematic };

struct RigidBodyComponent {
    BodyType type = BodyType::Dynamic;

    float mass = 1.0f;
    float inv_mass = 1.0f;
    glm::vec3 inertia = {1.0f, 1.0f, 1.0f};
    glm::vec3 inv_inertia = {1.0f, 1.0f, 1.0f};

    glm::vec3 velocity = {0.0f, 0.0f, 0.0f};
    glm::vec3 previous_velocity = {0.0f, 0.0f, 0.0f};
    glm::vec3 angular_velocity = {0.0f, 0.0f, 0.0f};
    glm::vec3 previous_angular_velocity = {0.0f, 0.0f, 0.0f};

    glm::vec3 force = {0.0f, 0.0f, 0.0f};
    glm::vec3 torque = {0.0f, 0.0f, 0.0f};

    float linear_damping = 0.01f;
    float angular_damping = 0.15f;
    float velocity_smoothing = 0.15f;
    float restitution = 0.1f;
    float friction = 0.9f;
    bool use_gravity = true;
    bool is_sleeping = false;
    bool is_grounded = false;

    // Sleep counters and support hints (simplified)
    int slow_frames = 0;
    uint32_t wake_cooldown_frames = 0;
    uint32_t pose_still_frames = 0;
    float last_support_dot_up = -1.0f;

    // Axis constraints (optional)
    bool freeze_position_x = false;
    bool freeze_position_y = false;
    bool freeze_position_z = false;
    bool freeze_rotation_x = false;
    bool freeze_rotation_y = false;
    bool freeze_rotation_z = false;
};

struct TransformComponent {
    glm::vec3 position = {0.0f, 0.0f, 0.0f};
    glm::quat orientation = {1.0f, 0.0f, 0.0f, 0.0f};
    glm::vec3 scale = {1.0f, 1.0f, 1.0f};
    bool matrix_dirty = true;
};

enum class ColliderType { Box, Sphere, Mesh };

struct ColliderComponent {
    ColliderType type = ColliderType::Sphere;
    glm::vec3 size = {0.5f, 0.5f, 0.5f}; // half-extents for box/mesh; x=radius for sphere
    glm::vec3 offset = {0.0f, 0.0f, 0.0f};
    bool is_trigger = false;
};

// For convex triangle-mesh colliders:
struct MeshColliderComponent {
    std::string mesh_path;
    std::vector<glm::vec3> vertices;
    std::vector<uint32_t> indices;
    bool convex = true;
};

The key constraint is that components contain data only. No methods, no inheritance, no hidden logic. The physics system owns the update operator and acts on contiguous views of these components. This is the pattern used in v::engine::systems::physics::PhysicsSystem: ECS snapshots are captured on the main thread, then parallel jobs operate on plain arrays.

Minimal entity setup (pseudocode)
entt::registry registry;
entt::entity ball = registry.create();

RigidBodyComponent rb;
rb.type = BodyType::Dynamic;
rb.mass = 1.0f;
rb.inv_mass = 1.0f / rb.mass;
rb.inertia = {0.4f, 0.4f, 0.4f};
rb.inv_inertia = {1.0f/0.4f, 1.0f/0.4f, 1.0f/0.4f};
rb.restitution = 0.1f;
rb.friction = 0.3f;
rb.linear_damping = 0.01f;
rb.angular_damping = 0.15f;

TransformComponent t;
t.position = {0.0f, 5.0f, 0.0f};
t.orientation = {1.0f, 0.0f, 0.0f, 0.0f};
t.scale = {1.0f, 1.0f, 1.0f};

ColliderComponent c;
c.type = ColliderType::Sphere;
c.size = {0.5f, 0.5f, 0.5f}; // x holds the radius for spheres

registry.emplace<RigidBodyComponent>(ball, rb);
registry.emplace<TransformComponent>(ball, t);
registry.emplace<ColliderComponent>(ball, c);

// Static ground:
entt::entity ground = registry.create();
RigidBodyComponent ground_rb;
ground_rb.type = BodyType::Static;
ground_rb.inv_mass = 0.0f;
ColliderComponent ground_c;
ground_c.type = ColliderType::Box;
ground_c.size = {10.0f, 0.5f, 10.0f}; // half-extents

TransformComponent ground_t;
ground_t.position = {0.0f, -0.5f, 0.0f};

registry.emplace<RigidBodyComponent>(ground, ground_rb);
registry.emplace<TransformComponent>(ground, ground_t);
registry.emplace<ColliderComponent>(ground, ground_c);
Anti-design: behavior in the object
A classic beginner mistake, and one visible in many one-week engines, is to give each body a Update(dt) method. That couples state to a hidden update order, makes parallelization hazardous, and prevents deterministic scheduling. Keep components data-only. The system owns the operator.

Step 2: Fixed timestep and symplectic Euler

Games do not run at a stable wall-clock interval. A frame may take 16 ms, 8 ms, or 33 ms. If you integrate with variable dt, your simulation is a function of frame pacing, which is a function of GPU load. The first hard rule of a shippable engine is: integrate at a fixed timestep, subdivided into substeps, and clamp or drop excess time.

Symplectic Euler is the standard game integrator. It is "velocity-first":

Integrate one body (pseudocode)
void IntegrateBody(body, transform, dt):
    if body.is_sleeping or body.type != Dynamic:
        return

    acceleration = gravity + body.force * body.inv_mass
    body.velocity += acceleration * dt
    body.angular_velocity += body.torque * body.inv_inertia * dt

    body.velocity *= exp(-linear_damping * dt)
    body.angular_velocity *= exp(-angular_damping * dt)

    body.velocity = clamp(body.velocity, -max_linear, max_linear)
    body.angular_velocity = clamp(body.angular_velocity, -max_angular, max_angular)

    transform.position += body.velocity * dt
    q_dot = 0.5f * quat(0, body.angular_velocity) * transform.orientation
    transform.orientation = normalize(transform.orientation + q_dot * dt)
Parabolic projectile trajectory with initial velocity, launch angle, range, and maximum height labeled
Figure 1. Public-domain projectile motion diagram (CC0, Wikimedia Commons). Symplectic Euler preserves the parabolic envelope for conservative motion better than explicit Euler. Download figure.

The quaternion derivative uses q_dot = 0.5 * ωq ⊗ q, where ωq = (0, ω). The final normalize is essential: without it the quaternion drifts off the unit sphere and the body begins to stretch. In the V Engine this is done inside physics_operators.hpp and called from the jobified integration path.

Anti-design: frame-rate dependent integration
The 7-day trial-and-error video updates position by adding velocity every frame with no fixed substep and no clamp. This is the fastest way to get a ball bouncing on the screen, and also the fastest way to get a simulation that behaves differently on different machines, on different frame rates, and on different days.

Step 3: Damping, clamps, and safety belts

A real-time engine must survive extreme inputs: teleporting an object deep inside another, tiny masses colliding with huge ones, high restitution near a wall. The safety system is simple and must be applied before the solver:

Safety pre-pass
void ApplySafetyBelts(body, dt):
    // Exponential damping removes energy predictably.
    body.velocity *= exp(-c_linear * dt)
    body.angular_velocity *= exp(-c_angular * dt)

    // Clamp velocities to a maximum safe magnitude.
    body.velocity = clamp_magnitude(body.velocity, v_max)
    body.angular_velocity = clamp_magnitude(body.angular_velocity, omega_max)

    // Optionally snap tiny velocities to zero when below a settling threshold.
    if length(body.velocity) < settle_linear and length(angular) < settle_angular:
        body.velocity *= 0.95

These are not just heuristics. In the Level-2 paper they are part of the contraction proof: exponential damping contributes a multiplicative gap factor κ = e−cΔt, and clamping is identified with the projection kernel, which is non-expansive. The math is exactly the code.

Step 4: Broadphase — the uniform spatial grid

Why broadphase?

If every object tested every other object, pair generation would be O(N2). At 1,000 bodies that is a million tests, far too many for a 16 ms frame. Broadphase separates potentially colliding pairs from pairs that are clearly too far apart. The goal is to produce a small candidate set that the expensive narrowphase can actually process.

What is an AABB?

An Axis-Aligned Bounding Box (AABB) is the smallest box with sides parallel to the world axes that fully contains a body. For a sphere the AABB is a cube whose side is the diameter. For a rotated box the AABB is computed from the rotated half-extents, so the AABB is typically larger than the box itself. The broadphase only ever sees AABBs; it does not know the real shape.

AABB: the broadphase only sees the world-aligned boxSphere: AABB is a cube of diameterRotated box: AABB is bigger
Figure 2a. Every collider has an AABB. The broadphase uses this world-aligned box because testing axis-aligned boxes is very fast and safe: if two AABBs do not overlap, the shapes inside them cannot overlap.

Grid cell insertion

Choose a cell size h roughly the size of a typical body. For each body, compute the AABB, divide the min and max corners by h, and insert the body into every integer cell in that range. A large body can appear in many cells; a small body usually appears in one. This is why the data structure is sometimes called a coarse grid: it deliberately over-approximates the set of candidate pairs.

Body ABody BAABB overlaps cells(Body A in 6 cells, B in 1)
Figure 2b. A uniform grid broadphase. Each body is inserted into every cell its AABB overlaps. Pairs are generated per cell and then deduplicated by normalized key.

Why are there two cells with the same body in Figure 2b? Because the AABB is larger than the visual circle. The broadphase does not care about the visual shape; it only cares about the conservative AABB. If an object moves slowly, it will usually stay in the same set of cells from frame to frame, which keeps the candidate pair list stable and helps the manifold cache warm-start.

Uniform grid broadphase (pseudocode)
struct CellCoord { int x, int y, int z; };

void BuildBroadphaseGrid(WorldState, float cell_size h):
    for body in WorldState.bodies:
        aabb = ComputeWorldAABB(body)
        i0 = floor(aabb.min.x / h); i1 = floor(aabb.max.x / h)
        j0 = floor(aabb.min.y / h); j1 = floor(aabb.max.y / h)
        k0 = floor(aabb.min.z / h); k1 = floor(aabb.max.z / h)
        for i in [i0, i1]:
            for j in [j0, j1]:
                for k in [k0, k1]:
                    grid[CellCoord(i,j,k)].push_back(body)

void GeneratePairs(WorldState):
    pair_set = empty_set()
    for cell, list in grid:
        for each unordered pair (A,B) in list:
            if CanCollide(A,B):
                pair_set.insert(NormalizePairKey(A,B))
    return sorted(pair_set)  // deterministic order

Determinism requires sorting the final pair list. Hash iteration order can differ between runs. In the V Engine this is handled by PhysicsSystem::generate_pairs() and the SpatialGrid implementation. For strict reproducibility, every iteration order in the engine must be a deterministic function of the input state.

Anti-design: no broadphase
A one-week engine often tests every body against every body. That works for twenty objects and immediately collapses at fifty. Broadphase is not an optimization you add later; it is the structure that makes a real-time engine possible.

What is a manifold? — a visual explainer

Before narrowphase runs, it helps to understand what it is producing. A manifold is a small data packet that summarizes one collision between two bodies. It is not the raw mesh overlap and it is not a list of every intersecting triangle; it is the minimum information the solver needs to push the two bodies apart without letting them tunnel or explode.

Body ABody BpenetrationnManifold Receiptentity_a = Body Aentity_b = Body Bnormal = (0, 1, 0)penetration = 0.12 mcontact_point = (1.2, 1.8, 0)restitution = 0.1friction = 0.5The solver reads this onecard, not the meshes.narrowphase
Figure 3a. A manifold is a receipt. It takes the raw mesh overlap and reduces it to: which two bodies, which direction they must separate, how deep the overlap is, and where the push should be applied. The solver only reads the receipt.

Why a "receipt"? Because once the narrowphase has done its expensive geometry work, the solver should not have to think about triangles again. The manifold records:

Contact force chains and entangled collisions

A single manifold is local: body A and body B. But bodies are usually connected into chains. A stack of three boxes is not three independent manifolds; it is a network where each manifold pushes on its neighbors and the forces propagate down to the ground. This is the physical meaning of "entangled" collisions: the manifold graph is the medium through which gravity, support, and friction forces travel.

Contact force chain: entangled collisionsStatic groundABCgn_BCn_ABn_GndAEach dashed line is a manifold.The solver must resolve allof them together, or the stackwill fall through the floor.
Figure 3b. A stack is a chain of manifolds. The top box pushes down through manifold BC, the middle box pushes down through AB, and the bottom box pushes down through GndA. The ground pushes back up through the same manifolds. The solver resolves this network, not each pair in isolation.

This is why warm-starting and contact islands matter so much. If manifold AB is solved first, it may think it is the only contact and push A down into the ground. The solver must iterate over the whole island many times, passing impulses back and forth, before the network converges. A stack that looks still to the eye is actually a solved equilibrium of many tiny forces.

Step 5: Narrowphase — from pairs to manifolds

Broadphase gives candidate pairs. Narrowphase decides which pairs actually overlap and, if they do, produces a contact manifold: a small set of contact points (usually 1–4) that approximate the local collision geometry.

A manifold stores, at minimum:

engine/systems/physics/physics_types.hpp (simplified)
struct CollisionManifold {
    entt::entity entity_a;
    entt::entity entity_b;
    glm::vec3 normal;          // points A -> B
    float penetration;         // overlap depth
    glm::vec3 contact_point;   // world-space push point
    float restitution;
    float friction;
};

struct CachedContact {
    glm::vec3 local_point_a;   // on body A, in local space
    glm::vec3 local_point_b;   // on body B, in local space
    glm::vec3 world_point;     // in world space
    glm::vec3 normal;          // points A -> B
    glm::vec3 tangent_1;       // first friction direction
    glm::vec3 tangent_2;       // second friction direction
    glm::vec3 r_a;             // world_point - position_a
    glm::vec3 r_b;             // world_point - position_b
    float penetration;
    float normal_impulse;
    float tangent_impulse_1;
    float tangent_impulse_2;
    float normal_mass;
    float tangent_mass_1;
    float tangent_mass_2;
    float velocity_bias;
    uint8_t lifetime;
    bool matched;
};

constexpr uint8_t MAX_CONTACTS_PER_MANIFOLD = 4;

struct CachedManifold {
    entt::entity entity_a;
    entt::entity entity_b;
    std::array<CachedContact, MAX_CONTACTS_PER_MANIFOLD> contacts;
    uint8_t contact_count = 0; // 1..4
    float friction;
    float restitution;
    uint32_t lifetime;
    bool updated_this_frame;
};
Body ABody Bncontact points p1, p2
Figure 3. A box-box manifold. The normal points from body A into body B. The two contact points approximate the contact patch. For stability, contact points should be computed in local coordinates and matched across frames.

For box-box or box-plane, the contact points should cover the contact patch. The most stable policy is to keep the points with the highest normal impulse (the load-bearing contacts) or the points that form the best support polygon. The V Engine uses ContactReductionPolicy::KeepStable by default, with KeepCorners and KeepDeepest as alternatives.

Narrowphase shapes in the V Engine

The current V Engine supports three collider types: sphere, box, and convex mesh. The actual implementation in engine/systems/physics/physics_job.cpp handles each combination:

Projecting two shapes onto one axiscandidate axis dAproj(A)Bproj(B)overlap = penetrationn
Figure 3c. SAT in one picture. If the two shapes overlap on every candidate axis, they are colliding. The axis with the smallest overlap gives the penetration and the normal. If any axis shows a gap, the shapes are separated and the test can stop early.

Contact point and material combination

Once the best axis is found, the engine computes the contact point from the support points of the two shapes along ±normal. For ground-like contacts (normal nearly aligned with gravity-up) the point is biased toward the upper body to avoid underestimating the moment arm and creating artificial edge-balanced equilibria. If one body is a huge static (e.g. the ground plane) and the other is a small dynamic, the contact point is kept near the dynamic body so the large static's corners do not create ghost contacts far from the real support.

Material properties are combined per manifold:

This combination is not a stylistic choice; it is what makes the friction cone consistent between two bodies of very different materials. If one body is ice and the other is rubber, the effective friction must be low.

Step 6: The manifold cache — the secret to stable stacks

Here is the most important architectural decision. Do not throw away your contact data at the end of the frame. Store it. The persistent manifold cache is keyed by the normalized entity pair and holds up to four contacts per pair. Each cached contact holds:

On the next frame, match newly detected contacts to existing slots by comparing local anchors. If a match exists, reuse the accumulated impulse as the initial guess. This is warm-starting, and it is why stable stacks settle in a few frames instead of wobbling forever.

Frame NFrame N+1cached contactnew contactsmatch local anchorsIf anchors are within ε_match, reuse cached λ
Figure 4. Manifold cache matching. As the body rotates, the world contact point moves, but the local anchor stays nearly the same. Matching by local anchor keeps impulses coherent across frames.

Impulses are not gameplay state. They are the solution guess of the constraint solver. Reusing them is the difference between a simulation that looks like glue and a simulation that looks like physics.

Anti-design: no manifold cache
The trial-and-error video builds a "distance constraint" and tries to enforce it by directly moving points. Without a persistent contact cache, every frame starts the solve from zero. This is the source of the famous stack jitter and the reason objects never quite rest. Warm-starting is not an advanced feature; it is the minimum requirement for stable contact.

Step 7: The constraint solver — PGS sequential impulses

With bodies integrated, pairs generated, manifolds detected, and the cache matched, the heart of the engine is the solver. We use Projected Gauss–Seidel (PGS) sequential impulses. The idea is to visit each contact repeatedly and apply a small impulse that pushes the relative velocity toward the desired target.

For each contact direction d (normal or one of the two tangents), compute the relative velocity at the contact point, compare it to a target that includes a bias term, multiply by the effective mass, and add the resulting Δλ to the accumulated impulse.

Solve one contact (PGS, simplified)
void SolveContact(body_a, body_b, cached_contact, dt):
    r_a = cached_contact.world_point - transform_a.position
    r_b = cached_contact.world_point - transform_b.position

    // Relative velocity at the contact point.
    v_rel = (body_b.velocity + cross(body_b.angular_velocity, r_b))
          - (body_a.velocity + cross(body_a.angular_velocity, r_a))

    // Normal constraint.
    vn = dot(v_rel, cached_contact.normal)
    d_lambda_n = -(vn + cached_contact.velocity_bias) * cached_contact.normal_mass
    new_lambda_n = max(0.0f, cached_contact.normal_impulse + d_lambda_n)
    d_lambda_n = new_lambda_n - cached_contact.normal_impulse
    cached_contact.normal_impulse = new_lambda_n
    ApplyImpulsePair(body_a, body_b, r_a, r_b, d_lambda_n * cached_contact.normal)

    // Friction constraints (two tangents).
    vt1 = dot(v_rel, cached_contact.tangent_1)
    vt2 = dot(v_rel, cached_contact.tangent_2)
    d_lambda_t1 = -vt1 * cached_contact.tangent_mass_1
    d_lambda_t2 = -vt2 * cached_contact.tangent_mass_2

    new_lt1 = cached_contact.tangent_impulse_1 + d_lambda_t1
    new_lt2 = cached_contact.tangent_impulse_2 + d_lambda_t2

    // Coulomb friction disk: ||λ_t|| ≤ μ * λ_n
    max_friction = cached_contact.friction * cached_contact.normal_impulse
    new_lt1, new_lt2 = ProjectToDisk(new_lt1, new_lt2, max_friction)

    d_lambda_t1 = new_lt1 - cached_contact.tangent_impulse_1
    d_lambda_t2 = new_lt2 - cached_contact.tangent_impulse_2
    cached_contact.tangent_impulse_1 = new_lt1
    cached_contact.tangent_impulse_2 = new_lt2
    ApplyImpulsePair(body_a, body_b, r_a, r_b,
                     d_lambda_t1 * cached_contact.tangent_1
                   + d_lambda_t2 * cached_contact.tangent_2)

The ApplyImpulsePair function is the physical law:

Apply impulse to a body pair
void ApplyImpulsePair(body_a, body_b, r_a, r_b, impulse):
    if body_a is dynamic active:
        body_a.velocity -= impulse * body_a.inv_mass
        body_a.angular_velocity -= body_a.inv_inertia * cross(r_a, impulse)
    if body_b is dynamic active:
        body_b.velocity += impulse * body_b.inv_mass
        body_b.angular_velocity += body_b.inv_inertia * cross(r_b, impulse)
PGS iterationsvelocity errorconvergence
Figure 5. PGS convergence. Each iteration reduces the total velocity error. With warm-starting, the first iteration starts close to the solution; without it, every frame begins from zero.

Step 8: Effective mass and the velocity bias

The solver needs two numbers at each contact: the effective massd(d) for direction d, and the velocity bias that tells the contact what velocity it should target.

Effective mass converts a velocity change into an impulse:

d(d) = mA−1 + mB−1 + d · ( (IA−1(rA × d)) × rA + (IB−1(rB × d)) × rB )

Effective mass scalar for an arbitrary contact direction d

In code, you compute the angular Jacobian term r × d, multiply by inverse inertia, and cross back with r. This gives the resistance the body offers to an impulse at that point in that direction.

The velocity bias has two parts:

  1. Baumgarte stabilization adds a small extra velocity to push bodies out of penetration over time: bias = β · max(0, penetration − slop) / dt. Larger β resolves overlap faster but can inject energy and jitter.
  2. Restitution adds bounce only for fast impacts: if vn < −vbounce, bias += −e · vn. For slow resting contacts, restitution is suppressed.

The combined bias is the target the solver aims at. The normal update becomes Δλn = −(vn + bias) · d(n).

Anti-design: raw position correction without bias
A naive engine often teleports overlapping bodies apart by the penetration depth. That is called position correction, and it is legitimate as a position-level pass, but it is not a replacement for the velocity bias. Teleporting without velocity adjustment creates explosive behavior in stacks and high-speed collisions.

Step 9: Contact islands and the sleep system

Once the solver is stable, the next problem is waste. A stack of a hundred sleeping boxes should cost almost nothing. The engine groups bodies into contact islands: connected components of the contact graph. An island can sleep as a unit if all its members are still and supported.

Contact IslandsIsland 1 (awake)Island 2 (sleeping)no contact edge
Figure 6. Contact islands. Two bodies are in the same island if they share a contact or a chain of contacts. Islands can sleep independently, and the solver can process them in parallel if no constraint data is shared.

Sleep criteria in the V Engine are multi-gated:

When a body sleeps, its velocity is zeroed and it is skipped by integration and the solver until an impulse, teleport, or moving overlap wakes it. This is a projection onto a closed set, which the Level-2 proof shows is non-expansive.

Axis constraints

Axis constraints are per-body freeze flags on position and rotation components. In the real RigidBodyComponent these are freeze_position_x/y/z and freeze_rotation_x/y/z. During apply_axis_constraints() the solver simply zeroes the corresponding velocity, force, and torque components. A common pattern is the player-style constraint: freeze X and Z rotation but leave Y (yaw) free; the system then converts any accumulated pitch/roll into a yaw-only quaternion so the character never falls over sideways. These constraints are projections onto an admissible subspace, so they are non-expansive in the contraction sense and help keep the player body readable.

Righting, settling, and the sleep stage

The job graph still has a legacy RightingCorrections stage, but in the current V Engine it is a no-op: all righting is now folded into update_sleep_states() so that the same code evaluates sleep, stabilizes, and aligns. When a box is nearly face-up but tilted a few degrees, the sleep evaluator applies a small righting angular acceleration around the axis that would bring the nearest face normal into alignment with gravity-up. This is clamped and only applied when the body has a contact hint and is not already face-aligned. It is a targeted, non-explosive correction that keeps dense stacks from resting on edges.

Step 10: Determinism and the Level-3 proof sketch

Determinism is not optional for AAA networked gameplay, replay systems, or debugging. It requires three things:

  1. Stable iteration order: sort pair lists, sort manifold lists, and never iterate unordered hash maps during simulation.
  2. Fixed arithmetic mode: same compiler flags, same floating-point settings, same order of operations.
  3. No parallel writes to shared constraint state: the solve stage remains serial, while broadphase and narrowphase can be parallel over independent chunks.

The Level-3 paper formalizes the parallel part. Worker-local manifold lists are merged by ordered concatenation. The per-body impulse accumulator is a sum over the flattened contact list, and because integer/floating-point addition is associative and commutative up to the chosen rounding mode, the total contribution is independent of chunk order. In the Lean 4 module this is Level3PhysicsSystem.totalContribution_bulkMerge_chunks.

AccB(Merge(M1,…,Mk)) = AccB0 + Σi Σc∈Mi ΔB(c)

Chunked accumulation is extensionally equal to serial accumulation (Level-3 Physics, §Fixed-Point Accumulator Algebra)

For the C++ engine, the practical takeaway is: make the merged contact list deterministic, then make the solver iterate it in that deterministic order. The GPU compute path in physics_system_gpu.cpp and physics_compute_pipelines.cpp follows the same invariant by writing worker-local results and then bulk-appending them.

Level-3 physics in action

The pipeline described in this post is the same one shown in the V Engine's Level-3 physics demo. The video below is a capture of that build from about six months ago. It is a good reference for what this blueprint produces, but it is not the latest internal state: the engine has since gained convex mesh colliders and other gameplay features that are part of the in-progress Level-4 work.

Level-3 physics in action on the M7 demo. Video: p3nGu1nZz, "GPU Physics Level 3 | Custom Vulkan 3D Game Engine in C++20 | V Engine | ECS | M7 Demo." Note: this is the proven Level-3 pipeline, not the still-in-research Level-4 system.

Step 11: GPU compute integration

When the body count is high and the GPU is underutilized, the integration and manifold-cache update stages can be moved to Vulkan compute shaders. The V Engine uses two compute pipelines:

The CPU still owns the solver, because the PGS iteration needs deterministic ordering and each contact modifies shared body state. The GPU path is a performance additive, not a replacement. The compute shaders are loaded relative to the executable directory and compiled from assets/shaders/ by CMake, not by hand.

Anti-design: GPU as a shortcut
A common mistake is to move the entire physics pipeline to the GPU because it is fast. The solver has cross-body memory dependencies that make naïve GPUization non-deterministic and divergent. Use the GPU for embarrassingly parallel stages (integration, broadphase, cache update), not for the solver.

Tuning parameters and their meaning

The engine has roughly a dozen knobs that control stability, cost, and look. The table below maps each symbol to a typical range and a rule of thumb.

SymbolMeaningTypical range
ΔtFixed timestep1/120 s to 1/30 s
sSubsteps per tick1 to 8
hBroadphase cell size≈ typical object diameter
NiterPGS iterations4 to 30
αwsWarm-start factor0.0 to 1.0
μCombined friction0.0 to 1.0+
eRestitution0.0 to 1.0
βBaumgarte bias0.0 to 1.0
slopAllowed penetration1 mm to 1 cm
cv, cωDamping per second0.0 to 2.0
vmax, ωmaxVelocity clampstuned to world scale
KManifold slots1 to 4

These constants live in engine/systems/physics/physics_constants.hpp and are loaded from config/physics.toml at runtime. Centralizing them makes the engine tunable without recompilation and makes the tuning visible to QA and designers.

What the numbers look like in practice

The V Engine physics system reports per-stage timings through PhysicsSystem::StageTimings and query/sleep telemetry. The benchmarks in docs/bench/data/physics_system.csv give a concrete sense of the cost:

~7–9
ms / frame
~120K
bodies / sec
~2,000
bodies / 16ms
~40–55
us / 50 cube narrowphase

Those numbers are from the linux-debug preset, so they include debug symbols and assertions. Release builds will be faster. The point is not the absolute magnitude — it is that the cost is measurable per stage, which makes the bottleneck visible. If the solver suddenly dominates, you raise the iteration count. If broadphase dominates, you enlarge the cell size. If narrowphase dominates, you simplify shapes or reduce manifold slots. Without stage timings, you are tuning by feel. The StageTimings struct in physics_system.hpp tracks integrate, grid build, pair generation, narrowphase, manifold cache, island build, warm start, solve, righting, axis constraints, and sleep as separate counters.

What the 7-day video gets wrong: a constructive post-mortem

Zanzlanz's video is a faithful record of learning physics by building it. The author explicitly says most of the time was spent struggling with rigid-body physics. That struggle is instructive, but the design choices that cause it are the exact ones a production engine avoids.

The anti-design checklist

No fixed timestep. The simulation runs once per frame with the frame delta. This makes it non-deterministic and frame-rate dependent.

No broadphase. The engine tests every candidate against every other candidate, which is O(n²) and scales poorly.

No persistent manifold cache. Contacts are created and destroyed every frame, so the solver starts from zero and stacks cannot rest.

No formal constraint solver. Distance constraints are enforced by direct position or velocity adjustments rather than a projected-impulse iteration. This injects energy and causes the "wiggle" glitch the author documents.

No warm-starting. Even if contacts were cached, there is no accumulated impulse to reuse, so every frame begins the solve from a cold state.

No sleep system. Bodies never stop computing, so micro-jitter persists and cost never drops.

No determinism plan. Iteration order depends on collection behavior and frame timing. Reproducing a bug requires reproducing the exact frame schedule.

Too much trial, not enough operator. The video is admirable as a learning project, but it treats each problem as an isolated hack to fix a symptom. A production engine is built from composable, provable stages.

The dot product is indeed the key math tool the author celebrates, and it is also the first math tool of a good engine. The difference is that in a good engine the dot product is embedded inside a pipeline, not used as a replacement for one.

The blueprint as a single implementation skeleton

Putting the pieces together, a minimal C++20 physics system has the following skeleton. This is not the full V Engine source, but it is the shape every correct implementation takes:

Minimal PhysicsSystem::FixedStep
void PhysicsSystem::fixed_step(float dt):
    substeps = clamp(floor(dt / fixed_timestep), 1, max_substeps)
    sub_dt = dt / substeps

    for i in 0..substeps-1:
        integrate_parallel(sub_dt)
        build_broadphase_grid()
        pairs = generate_pairs()
        manifolds = narrowphase(pairs)
        update_manifold_cache(manifolds)
        build_contact_islands()
        warm_start()
        solve_constraints_iterative(sub_dt)
        apply_righting_corrections(sub_dt)  // currently a no-op; righting is in sleep
        apply_axis_constraints()
        prune_stale_manifolds(3)
        update_sleep_states(sub_dt)         // righting and sleep happen together here

Each stage is a function with a deterministic contract. The extended state (bodies, cache) is transformed by the composite operator ΦΔt. The Level-2 paper proves contraction under explicit boundedness and non-expansiveness assumptions; the Level-3 paper proves that parallel chunking does not change the final accumulator.

Public-domain historical reference

The idea of integrating physical laws at discrete time steps is older than the games industry. Newton's Philosophiæ Naturalis Principia Mathematica (1687, English translation 1729) laid out the continuous laws we now discretize. The diagram below is from the 1729 edition and shows the same projectile-and-gravity problem that a modern engine solves every frame.

Historical diagram from Newton's Principia (1729 English translation) showing a projectile and centripetal force
Figure 7. Illustration from Newton's Mathematical Principles of Natural Philosophy (1729). Public domain. The same gravity, velocity, and parabolic envelope that a physics engine discretizes today. Download figure.

The difference between 1729 and now is not the physics. It is the algorithmic craft that makes the discretization stable, deterministic, and fast enough to run at 60 Hz.

The current V Engine: Level-4 is coming

As of the current build, the V Engine runs the Level-3 pipeline described here and is actively extending toward Level-4. The Level-4 additions include convex dynamic mesh colliders (already present as MeshColliderComponent and used in the gym_physics level), richer sleep/righting integration, and more gameplay-driven queries. Those extensions are still being benchmarked, tuned, and formalized; no Level-4 paper exists yet. When you read the source in engine/systems/physics/, the proven, documented pipeline is the Level-3 one; the Level-4 code paths are the active research frontier.

Caveats and limits

This blueprint covers impulse-based rigid-body dynamics for convex shapes. It does not cover everything an AAA engine might need:

Cross-references and further reading

Final thought

Building a physics engine from scratch in a weekend is a great way to learn what the dot product is for. Building one that ships in a game is a lesson in architecture: fixed timesteps, persistent caches, projected solvers, and deterministic operators. The V Engine's physics system is the result of iterating on those lessons, and the Level-2 and Level-3 papers are the formal record of why they work. Use this post as the blueprint, the papers as the proof, and the 7-day video as the caution.

"The dot product is not the engine. The engine is the disciplined composition of integrators, grids, manifolds, caches, and solvers. The dot product is just the first multiplication inside it."

Cat Game Research · V Engine Physics · 2026
← All transmissions