ChaosRumble - Implementation of a custom 2D physics engine

University

IT University of Copenhagen

Game

ChaosRumble

Date

2024

Duration

1 Month

Team Size

3

Main Roles

Physics Programmer, Gameplay Programmer

ChaosRumble was created for the Games Programming course at the IT University of Copenhagen. It is a small arena game where the player survives against endless waves of enemies, slaps them away, and tries to turn their knockback into even more chaos by bouncing them into walls and into each other.

Because this was a team project, I only want to talk about the parts I was actually responsible for. In our division of work, my focus was the physics side together with debug shapes, scene management, menus, audio playback, and hit feedback. Other parts such as player controls and sprite work were handled by my teammates, so this page stays deliberately scoped to my own contribution.

Important Links

Project repository: GitHub
Play it here: Itch.io

Physics Backbone

The whole game idea depends on collisions feeling readable. If enemies do not bounce well, the slap loses its punch and the game immediately becomes less interesting. So the main part of my work was building the physics loop that updates velocities, detects collisions, dispatches callbacks, and queues solver work.

void PhysicsEngine::step(float deltaTime)
{
    if (_physObjContainer.empty()) return;

    for (int i = 0; i < _physObjContainer.size(); i++) {
        _physObjContainer[i]->updateVelocity(deltaTime);
    }

    for (int i = 0; i < _physObjContainer.size() - 1; i++) {
        auto physCompA = _physObjContainer[i];

        for (int j = i + 1; j < _physObjContainer.size(); j++) {
            auto physCompB = _physObjContainer[j];

            glm::vec2 penetrationNormal;
            float penetrationDepth;

            if (collisionDetection(
                    physCompA,
                    physCompB,
                    penetrationNormal,
                    penetrationDepth)) {
                physCompA->callCollisionCallback(
                    physCompB->getGameObject(),
                    penetrationNormal);
                physCompB->callCollisionCallback(
                    physCompA->getGameObject(),
                    -penetrationNormal);

                _solver.push_back(std::make_shared<ImpulseSolver>(
                    physCompA,
                    physCompB,
                    penetrationNormal,
                    penetrationDepth));
            }
        }
    }

    for (auto s : _solver) {
        s->solve(deltaTime);
    }

    _solver.clear();
}

For collision detection I used a fast special case for circle-circle overlaps and then GJK with EPA for the more general shape combinations. That gave us enough flexibility for circles, rectangles, walls, and trigger-style interactions without having to build a much larger framework around it. The system is simple, but it was a good fit for a course project where the point was to understand the mechanics instead of hiding them behind a full engine.

Impulse response for slap chaos

Once a collision was detected, I resolved it with an impulse solver. The interesting part here was handling both collisions against static objects like arena borders and dynamic collisions between moving enemies. That split matters because the game feel changes a lot depending on whether the collision simply reflects motion or redistributes it between two bodies.

void ImpulseSolver::solve(float deltaTime)
{
    PhysicsComponent* staticObj = nullptr;
    PhysicsComponent* movableObj = nullptr;

    if (!_objA->movable)
        staticObj = _objA;
        movableObj = _objB;

    if (!_objB->movable) {
        if (staticObj) return;
        staticObj = _objB;
        movableObj = _objA;
    }

    if (staticObj && movableObj) {
        glm::vec2 vel = movableObj->getVelocity();
        float velDotNormal = glm::dot(vel, _normal);
        glm::vec2 impulse = 2 * velDotNormal * _normal;

        if (movableObj == _objA)
            _normal = -_normal;

        movableObj->setVelocity(vel - impulse);
        movableObj->getGameObject()->setPosition(
            movableObj->getGameObject()->getPosition() + _normal * _depth);
    } else {
        glm::vec2 velA = _objA->getVelocity();
        glm::vec2 velB = _objB->getVelocity();

        float velChange =
            (RESTITUTION_COEFFICIENT + 1) *
            (glm::dot(velB, _normal) - glm::dot(velA, _normal));
        float massFactor = 1 / _objA->getMass() + 1 / _objB->getMass();

        glm::vec2 impulse = (velChange / massFactor) * _normal;

        _objA->setVelocity(velA + (impulse / _objA->getMass()));
        _objB->setVelocity(velB - (impulse / _objB->getMass()));
    }
}

This was the system that made the game fantasy work. A slapped enemy should not just slide a bit. It should visibly launch away, ricochet, and sometimes take other enemies with it. That is where the title ChaosRumble starts to make sense.

Scene Flow And Feedback

My work on the project did not stop at physics. I also handled scene management and several of the glue systems that make a prototype feel properly playable. One important part was loading scenes from JSON in a controlled way, only after the previous scene's objects had been cleaned up. That kept transitions between menus, gameplay, and game over states manageable.

void SceneManager::transitionToScene(const std::string& sceneName)
{
    _sceneLoaded = false;
    _currentSceneName = sceneName;

    auto root = Engine::GetInstance()->GetRoot();
    for (auto obj : root->getChildren()) {
        obj->free();
    }
}

void SceneManager::loadScene()
{
    if (_sceneLoaded) return;

    std::string scenePath = "Data/Scenes/" + _currentSceneName + ".json";
    SetUpScene(scenePath);
    _sceneLoaded = true;
}

void SceneManager::SetUpScene(const std::string& path)
{
    std::ifstream file(path, std::ios::in);
    std::string jsonContent(
        (std::istreambuf_iterator<char>(file)),
        std::istreambuf_iterator<char>());

    picojson::value v;
    std::string err = picojson::parse(v, jsonContent);
    if (!err.empty()) {
        std::cerr << "JSON parsing error: " << err << std::endl;
        return;
    }

    auto rootArray = v.get("gameObjects").get<picojson::array>();
    for (const auto& value : rootArray) {
        Engine::GetInstance()->CreateGameObject(value);
    }
}

On top of that, I implemented the menu flow, audio component support, and simple hit feedback. None of these systems are very large in isolation, but together they are what turns a purely technical prototype into something a team can actually playtest. When a slap lands, the response needs to be visible and audible, not only physically correct.

void HitFeedback::update(float deltaTime)
{
    if (currentLifeTime >= lifeTime) {
        getGameObject()->free();
    }

    glm::vec2 newScale =
        initalScale *
        (glm::abs(lifeTime - currentLifeTime) / (lifeTime / 100) / 100);

    getGameObject()->setScale(newScale);
    currentLifeTime += deltaTime;
}

I like this snippet because it shows the scale of some of the finishing work. It is tiny, but it directly improves feel. That pattern came up repeatedly in ChaosRumble. Big systems such as physics gave the game structure, while small feedback systems made the structure readable.

Looking Back

The report already points out the places where I would push the project further: a fixed timestep for the physics update, better spatial partitioning, continuous collision detection for very fast objects, and eventually multithreaded solver execution. I still think this project was valuable because it let me work directly on engine-side systems instead of only writing gameplay scripts on top of an existing framework.

It was also a good reminder that in a team project, the useful thing is not to claim everything, but to be clear about what you actually owned. In ChaosRumble, that was the technical backbone that kept the bouncing, menus, loading, and feedback systems together.