Skip to content
C++
Domain Deep-Dive
Advanced

Physics Engine Integration in C++

Integrating PhysX, Bullet, Jolt, and Box2D into C++ game engines: rigid body dynamics, collision detection, constraint solvers, and determinism.

Physics engine landscape

EngineLanguageLicenseBest for
Jolt PhysicsC++17MITAAA games, excellent SIMD, modern API
PhysX 5C++17BSDUnreal Engine default, GPU acceleration
Bullet3C++zlibOpen-source, widely embedded
Box2D v3CMIT2D games, simple integration
HavokC++CommercialConsole AAA (historical standard)

Jolt Physics — the modern choice

Jolt is the fastest open-source 3D physics engine as of 2024. Designed from scratch for multi-core CPUs with SIMD throughout.

cpp
#include <Jolt/Jolt.h>
#include <Jolt/Physics/PhysicsSystem.h>
#include <Jolt/Physics/Body/BodyCreationSettings.h>

// Initialize Jolt (once at startup)
JPH::RegisterDefaultAllocator();
JPH::Factory::sInstance = new JPH::Factory();
JPH::RegisterTypes();

JPH::TempAllocatorImpl temp_allocator(10 * 1024 * 1024); // 10 MB
JPH::JobSystemThreadPool job_system(JPH::cMaxPhysicsJobs,
    JPH::cMaxPhysicsBarriers,
    std::thread::hardware_concurrency() - 1);

// Create physics system
JPH::PhysicsSystem physics;
physics.Init(
    1024,      // max bodies
    0,         // num body mutexes (0 = auto)
    1024,      // max body pairs
    1024,      // max contact constraints
    broad_phase_layer_interface,
    object_vs_broad_phase_layer_filter,
    object_vs_object_layer_filter
);

// Create a rigid body
JPH::BodyCreationSettings settings(
    new JPH::BoxShape(JPH::Vec3(1.0f, 1.0f, 1.0f)),
    JPH::RVec3(0.0f, 10.0f, 0.0f),      // position
    JPH::Quat::sIdentity(),               // rotation
    JPH::EMotionType::Dynamic,
    object_layer_moving
);

JPH::Body* box = physics.GetBodyInterface().CreateBody(settings);
physics.GetBodyInterface().AddBody(box->GetID(), JPH::EActivation::Activate);

Physics step loop

Physics runs at a fixed timestep (typically 60 Hz or 120 Hz), decoupled from the render frame rate:

cpp
class PhysicsWorld {
    JPH::PhysicsSystem m_system;
    float m_accumulator = 0.0f;
    static constexpr float FIXED_DT = 1.0f / 60.0f;

public:
    void Update(float delta_time) {
        m_accumulator += delta_time;

        while (m_accumulator >= FIXED_DT) {
            // Fixed timestep update
            m_system.Update(FIXED_DT, 1, &m_temp_alloc, &m_job_system);
            m_accumulator -= FIXED_DT;
        }

        // Interpolation factor for rendering (between 0 and 1)
        float alpha = m_accumulator / FIXED_DT;
        // Use alpha to interpolate rendered positions between
        // prev_transform and current_transform
    }
};

Why fixed timestep? Variable dt makes physics non-deterministic. Two runs of the same simulation with different frame times produce different results. Fixed timestep ensures determinism and stability.


Broad phase vs narrow phase

Collision detection is split into two stages:

cpp
All bodies                 Candidate pairs             Actual contacts
   │                            │                           │
   ▼                            ▼                           ▼
[Broad phase]  ──────────▶  [Narrow phase]  ──────────▶  [Contact solver]
(AABB tree /                (GJK/EPA or                  (constraint
 spatial hash)              shape-specific)               resolution)

Broad phase rejects pairs that can't possibly collide (AABB overlap test). This is O(N log N) instead of O(N²).

Narrow phase computes exact contact points, normals, and penetration depths for candidate pairs.

cpp
// Custom collision filter — only collide specific layers
class MyContactListener : public JPH::ContactListener {
public:
    JPH::ValidateResult OnContactValidate(
        const JPH::Body& body1, const JPH::Body& body2,
        JPH::RVec3Arg base_offset,
        const JPH::CollideShapeResult& result) override
    {
        // Return ValidateResult::AcceptContact or RejectContact
        if (body1.GetUserData() == body2.GetUserData())
            return JPH::ValidateResult::RejectContact; // same faction, no collide
        return JPH::ValidateResult::AcceptContact;
    }

    void OnContactAdded(const JPH::Body& body1, const JPH::Body& body2,
                        const JPH::ContactManifold& manifold,
                        JPH::ContactSettings& settings) override
    {
        // Play collision sound, spawn particles, etc.
    }
};

Character controller

Character controllers are kinematic bodies (no physics simulation), moved by code but colliding with the world:

cpp
#include <Jolt/Physics/Character/CharacterVirtual.h>

JPH::CharacterVirtualSettings char_settings;
char_settings.mShape = new JPH::CapsuleShape(0.9f, 0.3f); // height, radius
char_settings.mMaxSlopeAngle = JPH::DegreesToRadians(45.0f);
char_settings.mMaxStrength = 100.0f;
char_settings.mMass = 70.0f;

auto character = std::make_unique<JPH::CharacterVirtual>(
    &char_settings, JPH::RVec3(0, 2, 0), JPH::Quat::sIdentity(), &physics
);

void UpdateCharacter(float dt, JPH::Vec3 move_input, bool jump) {
    JPH::Vec3 velocity = character->GetLinearVelocity();

    // Apply gravity
    velocity.SetY(velocity.GetY() - 9.81f * dt);

    // Apply movement
    if (character->IsSupported()) {
        velocity.SetX(move_input.GetX() * 5.0f);
        velocity.SetZ(move_input.GetZ() * 5.0f);
        if (jump) velocity.SetY(5.0f);
    }

    character->SetLinearVelocity(velocity);
    character->Update(dt, gravity, broad_phase_layer_filter,
                      object_layer_filter, body_filter, {}, temp_alloc);
}

Determinism and multiplayer

For deterministic multiplayer physics:

  1. Use fixed-point math or be very careful about float ordering
  2. Run same timestep on all machines (fixed 60 Hz)
  3. Seed random number generators identically
  4. Avoid non-deterministic operations: std::unordered_map iteration order, thread scheduling
cpp
// Deterministic step — same inputs MUST produce same output
struct PhysicsInput {
    uint64_t frame;
    std::array<PlayerInput, MAX_PLAYERS> inputs;
};

// Lock-step: all clients simulate the same frame simultaneously
void SimulateFrame(const PhysicsInput& input) {
    // Apply inputs in deterministic order
    for (int i = 0; i < MAX_PLAYERS; ++i) {
        ApplyPlayerInput(i, input.inputs[i]);
    }
    physics.Update(FIXED_DT, 1, &temp_alloc, &job_system);
}

Performance tips

  • Sleep inactive bodies — most physics engines do this automatically; ensure your active body count stays reasonable
  • Compound shapes — one compound shape per entity is faster than many separate bodies
  • Simplified collision meshes — use convex hulls or simple primitives for most objects; only use triangle meshes for terrain
  • Layer filtering — assign layers and filter early in broad phase (e.g., projectiles don't collide with other projectiles)
  • Query caching — cache raycast results if you cast the same ray multiple times per frame
cpp
// Use layer filtering to avoid O(N²) collision pairs
// Layer 0: static world geometry
// Layer 1: dynamic objects
// Layer 2: sensors/triggers (no collision response)
// Layer 3: projectiles (collide with 0,1 but not 3)

constexpr JPH::ObjectLayer LAYER_STATIC    = 0;
constexpr JPH::ObjectLayer LAYER_DYNAMIC   = 1;
constexpr JPH::ObjectLayer LAYER_SENSOR    = 2;
constexpr JPH::ObjectLayer LAYER_PROJECTILE = 3;
Edit on GitHubUpdated 2026-05-24T00:00:00.000Z