Weapon Systems, Physics & Gameplay Programming - Custom C++ FPS Engine


Together with a team (5 more programmers) built a playable first-person shooter entirely from scratch in C++ over 6 weeks, targeting both PC and PS5. The engine used an Entity-Component-System architecture with Jolt Physics for all simulation.

Team size: 6 programmers     Duration: 8 weeks     Platform: PC / PS5

What did I do?


Contents


Data-Driven Weapon System

My first attempt used a base class with virtual methods for each weapon type. The deeper I got into the firing logic, the more I realised that approach would mean a new class every time a designer wanted a new gun. I rebuilt it around a single configuration struct instead, no subclasses, no code changes needed to add a weapon.

Every weapon in the game is just a WeaponConfiguration. No subclasses, no switch statements scattered across the codebase. The system reads the data and handles the rest.

// The complete weapon config, designers can adjust these values via ImGui
struct MainFireSettings {
    float  damage         = 10.f;
    float  range          = 100.f;
    float  rateOfFire     = 0.1f;    // seconds between shots
    float  reloadSpeed    = 2.f;
    int    ammoCapacity   = 30;
    bool   isHitScan      = true;

    enum FireMode  { Auto, Single, Burst } fireMode = Auto;
    enum BulletType { SingleBullet, Multiple }  bulletType = SingleBullet;
};

The full WeaponConfiguration composes seven sub-structs: fire settings, recoil, spread, projectile, burst, model, and multiple-bullet settings - all with sensible defaults so you only specify what differs per weapon:

struct WeaponConfiguration
{
    string                  name;
    MainFireSettings        mainFireSettings;
    WeaponRecoilLogic       recoilSettings;
    ProjectileSettings      projectileSettings;   // used when isHitScan = false
    SpreadSettings          spreadSettings;        // velocity-based accuracy falloff
    MultipleBulletsSettings multipleBulletsSettings; // shotgun pellet count & spread
    BurstSettings           burstSettings;
    ModelSettings           modelSettings;         // 3D model path + transform offset
};

Adding a new weapon is one constructor call. Here’s the AK-47 preset:

WeaponConfiguration("AK-47",
    MainFireSettings(/*damage*/ 25.f, /*range*/ 100.f, /*rateOfFire*/ 0.1f,
                     /*reloadSpeed*/ 2.6f, /*ammo*/ 30, /*isHitScan*/ true,
                     FireMode::Auto, BulletType::SingleShot),
    ModelSettings("models/ak47.glb", /*yRotation*/ 0.f,
                  /*offset*/ glm::vec3(0.45f, -0.2f, -0.8f)),
    WeaponRecoilLogic(/*meanX*/ 0.f, /*meanY*/ 0.4f,
                      /*varianceX*/ 0.4f, /*varianceY*/ 0.2f,
                      /*damping*/ 0.6f, /*maxAccumulation*/ 10.f))

Each struct is also registered with VISITABLE_STRUCT, which the engine’s serialization system uses to automatically save and load weapon configs to disk - no manual read/write code needed per field:

VISITABLE_STRUCT(bee::weapon::WeaponConfiguration,
                 mainFireSettings, multipleBulletsSettings,
                 recoilSettings, spreadSettings,
                 projectileSettings, burstSettings, name);

VISITABLE_STRUCT(bee::weapon::MainFireSettings,
                 damage, range, rateOfFire, reloadSpeed,
                 ammoCapacity, isHitScan, fireMode, bulletType);

This meant designers could save a tuned weapon config in the ImGui panel and reload it next session - no hardcoding required.

On top of that, I built a full ImGui panel inside the engine inspector so designers could live-tweak every parameter: damage, spread, burst delay, recoil variance and see the result immediately without recompiling. This became the primary tuning tool during the final weeks.


Hitscan vs. Projectile - Choosing the Right Approach

Real games use two completely different approaches to bullets, and they feel different. I implemented both and exposed the choice as a single boolean in the weapon config.

Hitscan fires a ray through Jolt’s narrow-phase query and resolves the hit instantly. It’s correct for rifles - the bullet travels faster than the frame rate anyway, so simulating it physically adds nothing.

void WeaponsSystem::PerformRaycast(glm::vec3 rayOrigin, glm::vec3 rayDirection)
{
    RRayCast rc(ToJolt(rayOrigin), ToJolt(rayDirection * 10000.f));
    RayCastResult res;

    bool hit = physicsSystem.GetInternalSystem()
        ->GetNarrowPhaseQuery()
        .CastRay(rc, res, broadPhaseFilter);

    if (hit)
    {
        auto entity = static_cast<bee::Entity>(hit_body.GetUserData());

        if (ecs.Registry.try_get<AgentAi>(entity))
            ecs.Registry.get<AgentAi>(entity).AgentHit(currentWeapon.mainFireSettings.damage);
        else
            SpawnBulletHoleDecal(hitPoint, surfaceNormal);
    }
}

Projectile spawns a physical Jolt body with an impulse applied to it. The bullet flies through the scene, interacts with gravity, and collision is handled by the contact listener. This is correct for slower projectiles where arc and bounce matter.

void WeaponsSystem::SpawnProjectile(const glm::vec3& scale, float speed,
                                    glm::vec3 direction, const float& damage)
{
    auto projectileEntity = Engine.ECS().CreateEntity();
    Engine.ECS().CreateComponent<Bullet>(projectileEntity).damage = damage;

    SphereShapeSettings shapeSettings(scale.x);
    physicsSystem.AddPhysicsBody(projectileEntity, transform, shapeSettings,
                                 /*mass*/ 0.2f, /*friction*/ 0.f, /*static*/ false);

    // Apply velocity as impulse so Jolt owns the trajectory
    bodyInterface.AddImpulse(bodyID, ToJolt(direction * speed));
}

Recoil - Modelled Statistically

Most beginner recoil systems just add a fixed offset to the camera each shot. That produces recoil that feels mechanical and predictable. I wanted spray patterns that felt like real guns: consistent enough to learn, random enough to stay tense.

The approach: each shot samples from a normal (Gaussian) distribution with configurable mean and variance per axis. The results accumulate, get clamped to a maximum, then recover over time via exponential damping.

void WeaponRecoilLogic::applyRecoil(glm::quat& cameraRotation)
{
    // Sample from normal distribution - unpredictable but tunable
    float recoilX = getRandomOffset(meanX, varianceX, canGoBeyondVariance);
    float recoilY = getRandomOffset(meanY, varianceY, canGoBeyondVariance);

    accumulatedX = std::clamp(accumulatedX + recoilX, -maxAccumulation, maxAccumulation);
    accumulatedY = std::clamp(accumulatedY + recoilY, -maxAccumulation, maxAccumulation);

    // Apply pitch and yaw relative to the camera's current orientation
    glm::vec3 right = cameraRotation * glm::vec3(1, 0, 0);
    glm::vec3 up    = cameraRotation * glm::vec3(0, 1, 0);

    glm::quat pitch = glm::angleAxis(glm::radians((float)accumulatedY), right);
    glm::quat yaw   = glm::angleAxis(glm::radians((float)accumulatedX), up);
    cameraRotation  = yaw * pitch * cameraRotation;

    // Strip roll - reconstruct from forward direction to prevent camera drift
    glm::vec3 newForward     = cameraRotation * glm::vec3(0, 0, -1);
    glm::vec3 correctedRight = glm::normalize(glm::cross(glm::vec3(0, 1, 0), newForward));
    glm::vec3 correctedUp    = glm::normalize(glm::cross(newForward, correctedRight));
    cameraRotation           = glm::quatLookAt(newForward, correctedUp);
}

void WeaponRecoilLogic::updateRecoil(float deltaTime)
{
    // Exponential decay back to zero
    accumulatedX = glm::mix(accumulatedX, 0.f, dampingRecovery * deltaTime);
    accumulatedY = glm::mix(accumulatedY, 0.f, dampingRecovery * deltaTime);
}

The roll-lock step was not in the original design, I found through testing that without it, sustained fire slowly rotated the camera on its Z-axis, which felt broken. Reconstructing the rotation from the forward vector every frame fixed it cleanly.

Designers tune meanX, meanY, varianceX, varianceY, maxAccumulation, and dampingRecovery from the ImGui panel to shape the feel of each weapon.


Physics Collision Without Coupling

The problem with collision callbacks is that they execute inside Jolt’s physics step, on Jolt’s terms, not the ECS’s. The naive approach is to reach directly into game systems from inside the callback. That couples physics to gameplay and makes both harder to maintain.

My approach: the collision listener only reads component tags. It doesn’t know what a “bullet” does, it just checks whether the entity has a Bullet component or an AgentAi component, then calls the right interface.

void WeaponCollisionListener::OnContactAdded(const JPH::Body& body1,
                                             const JPH::Body& body2, ...)
{
    bee::Entity e1 = static_cast<bee::Entity>(body1.GetUserData());
    bee::Entity e2 = static_cast<bee::Entity>(body2.GetUserData());

    // Bullet hits AI agent - apply damage through the agent's interface
    if (IsBullet(e1) && IsAgent(e2))
        Engine.ECS().Registry.get<AgentAi>(e2)
                             .AgentHit(Engine.ECS().Registry.get<Bullet>(e1).damage);

    // Destroy bullet on impact if weapon config says so
    if (currentWeapon.projectileSettings.destroyAfterCollision)
    {
        if (IsBullet(e1) && !IsBullet(e2)) Engine.ECS().DeleteEntity(e1);
        if (IsBullet(e2) && !IsBullet(e1)) Engine.ECS().DeleteEntity(e2);
    }
}

// Tag checks - physics system never needs to know game logic details
bool WeaponCollisionListener::IsBullet(bee::Entity e)
    { return Engine.ECS().Registry.try_get<Bullet>(e) != nullptr; }

bool WeaponCollisionListener::IsAgent(bee::Entity e)
    { return Engine.ECS().Registry.try_get<AgentAi>(e) != nullptr; }

This kept the physics layer ignorant of game logic and made it easy to add new collision cases (like the bouncing bullet mode) without touching existing code.

preview

View WeaponCollisionListener on GitHub


Player Movement

I implemented walking, running, and jumping on top of Jolt’s character controller. The movement itself wasn’t the hard part - getting it to feel right was.

The character controller went through two major debugging cycles. The first was an old Jolt version incompatibility: the collision detection API had changed, which meant the approach that worked in the docs didn’t compile on our codebase. Tijn (my teammate) and I tracked it down, found an alternative collision detection path that worked with our version, and got it stable.

The second issue was subtler - the controller was passing through thin geometry at high speeds, a classic tunneling problem. I fixed it by adjusting the shape padding and switching to a more conservative collision step size, which solved it without affecting the feel of normal movement.

The main tuning work after that was acceleration curves and jump response. Floaty jumps and instant stops both feel wrong in an FPS, so I iterated on the force application and damping values until the movement matched what you’d expect from the genre.

preview

Beyond my own features, I spent consistent time reviewing teammates’ pull requests on GitHub, checking for architectural issues, suggesting naming improvements, and flagging cases (very common once) where gameplay code was leaking into engine systems.


Final Showcase

What I Learned

Go Back

BUAS