My Contributions to Rounds but worse…


A level in Rounds but worse…

We were a team of five students from the Florida Interactive Entertainment Academy (FIEA) who collaborated to develop a clone of an existing game using custom game engines that each of us built during the semester.

To better understand our project, I encourage you to check out the actual game on Steam, which served as our reference point. You can also watch this short gameplay trailer for a quick overview.

Project Overview

This was a four-week-long project, with specific milestones for each phase:

  • Pre-Production

  • Production 1

  • Production 2

  • Final

My Game Engine Development

In the game engine I developed, I had not implemented libraries for rendering or physics—both of which were essential for the game our team aimed to create. My custom engine was designed from the ground up to primarily ingest game content written in JSON and translate it into a hierarchy of equivalent C++ classes.

Since my engine lacked built-in graphics and physics capabilities, the team decided to integrate Raylib for graphics and Box2D for physics. We carefully wrapped the necessary functions as engine services to ensure smooth interoperability.

Throughout this journey, I gained valuable insights into the core components of a game engine. I have outlined these key pieces in detail in a separate article, which I highly recommend checking out.

For a brief overview, here are some of the fundamental aspects of my engine:

Engine Architecture

  • Attributed: In my content-driven engine, an Attributed is a named, typed data field that can be defined within C++ class definitions or injected at runtime via JSON. Under the hood, each Attributed object manages a collection of Datums—flexible containers that can store scalars, vectors, tables (nested scopes), and more, all referenced by string keys. This allows designers to define new properties (e.g., position, health, speed, custom behaviors) directly in JSON without modifying C++ code, enabling seamless gameplay system integration.

  • Scope: The Attributed class is derived from Scope, which is responsible for managing a table of string-keyed Datums.

  • Action: The Action class, derived from Attributed, enables users to script behaviors through game content. It includes an Update function that returns a boolean indicating whether the action is completed or ongoing. The Update function calls three virtual methods:

    • Init: Optional initialization for the action, returning a boolean that determines whether it should complete without running.

    • Run: Executes the action and returns a boolean indicating completion.

    • Cleanup: Handles any necessary cleanup following initialization.

  • ServiceManager: The engine incorporates a ServiceManager, which provides a structured method for managing various services while gatekeeping access through controlled interfaces.


Pre-Production

The goal for this week was for the team to orient themselves, plan the development process, and identify the technologies and features needed to build the game.

The project planning our team did during the initial days of the pre-production week, charting out the ideal way we wanted things to get done with deadlines to make a polished game.

I took on the responsibility of developing a Prefab service for our game, as the team agreed that implementing prefabs would significantly improve our content creation workflow. Prefabs would enhance efficiency, reduce errors, and streamline game development.

What Are Prefabs?

Prefabs are saved instances of classes that can be duplicated at runtime, allowing for flexible content creation. The prefab service enables content creators to define game elements in a structured and efficient way by referencing prefabs instead of redefining them each time.

We relied heavily on prefabs throughout the game, using them for repeating or reusable elements such as:

  • Player characters

  • Bullets

  • Cards

  • Entire levels

Integrating the Prefab Service

Our engine uses a ServiceManager, which provides a structured way for users to create and manage services. To expand the engine’s capabilities, I introduced an additional service—the Prefab service—to the list of available services.

I designed the following interfaces for the Prefab service:

  • IPrefabProvider: Enables game content to reference and utilize prefabs.

  • IPrefabRegistrar: Allows designers to create custom prefabs tailored to their needs.

  • IPrefabSwapper: Supports dynamic swapping of prefab data at runtime.

The Need for Prefab Swapping

Prefab swapping wasn’t an intuitive need at first—it emerged during the second week of development. Initially, the team intended to modify prefabs directly, but we later realized that this wasn't possible within our existing setup. The PrefabSwapper interface was introduced to address this limitation, allowing prefabs to be modified and preserved dynamically.

For example, prefab swapping was essential when cards modified bullet stats, and the modified bullet needed to be stored and used as a prefab.

   class PrefabService : public IPrefabRegistrar, public IPrefabProvider, public IPrefabSwapper
   {
       FIEA_SERVICE_BIND3(PrefabService, IPrefabProvider, IPrefabRegistrar, IPrefabSwapper);
       
       virtual void Shutdown() override;
       PrefabService();
       PrefabService(const PrefabService& rhs) = delete;
       PrefabService(PrefabService&& rhs) noexcept = delete;
       PrefabService& operator=(const PrefabService& rhs) = delete;
       PrefabService& operator=(PrefabService&& rhs) noexcept = delete;
       ~PrefabService() = default;

   protected:
       virtual Content::Scope* GetPrefab(const std::string& prefabName) const override;
       virtual void RegisterPrefabsFromScope(const Content::Scope& rootScope) override;
       virtual void SwapPrefab(const std::string& prefabName, std::unique_ptr<Content::Scope>&& newPrefab) override;
   private:
       std::unordered_map<string, std::unique_ptr<Content::Scope>> _Prefabs;
   };


Unordered map with key value pairs of prefab name and unique pointer to the scope object was used to store the prefabs. When a user calls GetPrefab, the function creates a clone of the prefab and returns it.

This is an example of how I used GetPrefab and SwapPrefab during the initialization of the default player stats.

Using the RegisterPrefabFromScope function to register the level as a prefab. After doing this, the level could be easily used in other places.


Production 1: First Playable

The goal for this week was to develop something akin to a first playable version of the game.

Bullet Mechanics Development

This week, I worked on implementing the bullet shooting feature. There were several complexities involved, including spawning, shooting, movement, and collision handling.

To achieve this, I created data-driven actions for firing bullets:

  • ActionFireBullet

  • ActionSetLinearVelocity

ActionFireBullet Implementation

The ActionFireBullet runs continuously on the player, checking for input from the Right Trigger and Right Analog Stick. When triggered, it:

  1. Calls the Prefab service, which holds the current bullet prefab.

  2. Applies properties such as speed, radius, and damage from the player's scope to the bullet.

  3. Assigns the bullet as a child of the player object.

Since my engine relies on prefab-based spawning, I utilized the Prefab service to retrieve the bullet prefab.

 auto bulletPrefabAsScope = prefabProvider->GetPrefab(*_BulletPrefabPtr);

Spawning & Directional Control

To ensure the bullet spawns at the correct position and direction, I used:

  • The tip of the player's hand as the spawn point.

  • The player's rotation, a remarkable and complex feature developed by my teammate Josh Brading.

Unlike engines where spawning is a standalone function, in my design, simply assigning the bullet as a child object ensures that its Update() and Draw() functions start running automatically.

 vec4 handRotation = GetGameObjectParent()->As<Player>()->GetPlayerHandRotation();
 FVector2 direction = {handRotation.x, handRotation.y};

 auto playerHandTip = playerObj->GetPlayerTipOfHandWorldPosition();
 // Normalize the direction vector
 float magnitude = sqrt(direction.x * direction.x + direction.y * direction.y);
 if (magnitude > 0) {
     direction.x /= magnitude;
     direction.y /= magnitude;
 }

 direction.x = -direction.x;
 direction.y = -direction.y;

 // Offset the spawn position
 float offsetDistance = 10.0f;
 FVector2 spawnPosition = {
     playerHandTip.x + direction.x * offsetDistance,
     playerHandTip.y + direction.y * offsetDistance
 };
 player->Adopt("ChildObjects", *bulletPrefab);
 bulletPrefab->Init(spawnPosition);
 bulletPrefab->_XVelocity = direction.x;
 bulletPrefab->_YVelocity = direction.y;
 bulletPrefab->_Speed = player->As<Player>()->_BulletModifiedSpeed;
 bulletPrefab->_Damage = player->As<Player>()->_BulletDamage;

Bullet Movement & Physics

Once the bullet becomes a child of the player, it activates its ActionSetLinearVelocity, which:

  1. Retrieves the physics handle of the bullet.

  2. Applies linear velocity to propel it forward.

All physics functionality in the game is powered entirely by Box2D.


Production 2

This should technically be the final week for major feature additions. At the start, I knew I had a few significant tasks ahead—collision handling and particle effects, such as bullets exploding into fragments.

Collision Handling

Handling collisions turned out to be easier than expected, since Box2D already provides default collision detection. My main challenge was defining specific behaviors for certain collisions. The Box2D API allows users to define a custom class inheriting from b2ContactListener, which enables tailored functionality upon collision events.

The physics engine automatically invokes BeginContact when two colliders first interact, and EndContact when they separate. To leverage this API, I added virtual functions—OnCollisionEnter and OnCollisionExit—to the GameObject class, allowing for customized collision responses.

By making it a virtual function, A derivative class such as Bullet or Player can define behavior that should happen to them.

To enable references back to GameObject from the physics bodies managed by the physics engine, I needed an efficient way to link the two. Fortunately, Box2D anticipated this requirement, allowing users to attach a void* pointer to the physics body’s user data. This feature made it possible to store and retrieve custom object references, simplifying interaction between game objects and their physical counterparts.

  using Contact = b2Contact;

  class ContactListener : public b2ContactListener
  {
      virtual void BeginContact(Contact* contact) override;
      virtual void EndContact(Contact* contact) override;
  };

  void ContactListener::BeginContact(b2Contact* contact)
  {
        using namespace Fiea::Engine;
        b2Fixture* fixtureA = contact->GetFixtureA();
        b2Fixture* fixtureB = contact->GetFixtureB();

        // Assume user data was set to point to game object
        GameObject* objectA = static_cast<GameObject*>(fixtureA->GetBody()->GetUserData());
        GameObject* objectB = static_cast<GameObject*>(fixtureB->GetBody()->GetUserData());

        // Check if the user data exists and then trigger collision events on both objects
        if (objectA && objectB) {
            objectA->OnCollisionEnter(objectB);
            objectB->OnCollisionEnter(objectA);
        }
    }

   void ContactListener::EndContact(Contact* contact) 
   {
         using namespace Fiea::Engine;
        b2Fixture* fixtureA = contact->GetFixtureA();
        b2Fixture* fixtureB = contact->GetFixtureB();

        void* objA = fixtureA->GetBody()->GetUserData();
        void* objB = fixtureB->GetBody()->GetUserData();
        GameObject* gameObjectA = nullptr, *gameObjectB = nullptr;

        if (objA != nullptr)
        {
            gameObjectA = reinterpret_cast<GameObject*>(fixtureA->GetBody()->GetUserData());
        }
        if (objB != nullptr)
        {
            gameObjectB = reinterpret_cast<GameObject*>(fixtureB->GetBody()->GetUserData());
        }
        if (gameObjectA)
        {
            gameObjectA->OnCollisionExit(gameObjectB);
        }
        if (gameObjectB)
        {
            gameObjectB->OnCollisionExit(gameObjectA);
        }
    }

Pass through blocks

Rounds had a unique feature—pass-through blocks. These special blocks don’t interact with players or bullets but behave like regular blocks when colliding with other blocks. Once again, Box2D provided a built-in solution to implement this, ensuring seamless integration of the mechanic.

PhysicsService.h:

enum CollisionCategory : uint16_t
  {
      CATEGORY_NONE = 0x0000,
      CATEGORY_BLOCK = 0x0001,
      CATEGORY_PLAYER = 0x0002,
      CATEGORY_BULLET = 0x0004,
      CATEGORY_INVISIBLE_BLOCK = 0x0008
  };

PrimitiveBox.cpp:

case 1: //Static block
{
    _Body->SetCollisionCategory(Engine::Physics::CollisionCategory::CATEGORY_BLOCK);
    _Body->SetCollisionMaskBits(Engine::Physics::CollisionCategory::CATEGORY_PLAYER | Engine::Physics::CollisionCategory::CATEGORY_BULLET | Engine::Physics::CollisionCategory::CATEGORY_INVISIBLE_BLOCK | Engine::Physics::CollisionCategory::CATEGORY_BLOCK);
    break;
};
case 2: // Pass through block 
{
    _Body->SetCollisionCategory(Engine::Physics::CollisionCategory::CATEGORY_INVISIBLE_BLOCK);
    _Body->SetCollisionMaskBits(Engine::Physics::CollisionCategory::CATEGORY_BLOCK | Engine::Physics::CollisionCategory::CATEGORY_INVISIBLE_BLOCK);
    break;
}

Particle Effects

Adding particle effects was a crucial task, significantly enhancing the game’s production value.

Initially, I brainstormed different approaches with Josh. He suggested drawing a few particles in a circle around the collision point and making them disperse outward, mimicking centrifugal force. While this method worked well for one specific effect, it was cumbersome and limited in versatility. Since our team planned to implement multiple particle effects, I decided to explore alternative solutions.

That’s when I had a breakthrough—why not leverage the vast number of free explosion GIFs available online? This idea led me to develop a sprite animation system capable of playing any particle effect provided to it.

Particle Effects Service Design

To implement this system efficiently, I structured the following interfaces:

  • IEffectLoader – Loads effects into the service.

  • IEffectProvider – Allows GameObjects to request effects like "explosion."

  • IAnimator – Manages and executes RunningEffects, clearing them once they complete.

EffectService. Like any other service in the engine, this service can be used via the service manager and through the interfaces.

RunningEffectsTable

The RunningEffectsTable stores data entries containing an Entity and its associated RunningEffect. The Entity itself is a simple struct that holds a size_t EntityId and the position of the GameObject. Since the EntityId is unique for each object, this implementation ensures that a single entity cannot have multiple effects simultaneously.

To facilitate proper hashing for the Entity struct—since the entity map does not inherently support it—I provided a template overload for the hash class. This ensures efficient mapping and retrieval.

Effect Class Structure & Encapsulation

To manage the various responsibilities of an effect, I designed a hierarchy of classes with increasing levels of encapsulation:

  • Effect – Holds unchanging data about a particle effect, such as the source texture and frame information.

  • EffectInstance – Allows multiple objects to utilize the same effect but with different properties, such as varying frame rates or loop counts.

  • RunningEffect – Tracks runtime-specific details, including the current frame, loop iteration, elapsed time for the current frame, and when to transition to the next frame.


Final Week: Bug Fixes & Polish

This week was intended for fixing bugs and adding polish—though, as expected, that was the ideal case. Alongside resolving numerous errors, we had some ambitious goals we had promised ourselves, provided the game was in solid shape early in the week.

Deletion Service

Until now, object deletion was handled inconsistently, relying on a hacky approach that only worked in certain cases. I took on the task of developing a formal API to properly address this issue.

At the heart of the problem was a fundamental question: What happens if an object deletes itself inside its own member function? Could it corrupt the call stack? Possibly.

To solve this, I introduced a DeletionService that defers deletion. This service allows objects to be marked for deletion and ensures they are safely removed at the end of the update cycle. Additionally, it uses an unordered set to prevent accidental double deletion, which could otherwise crash the game.

Rope Mechanic

Since the game was shaping up well early in the final week, I decided to tackle the rope mechanic—a unique feature that sets our game apart. Implementing this required careful attention to physics and player interactions, ensuring smooth and dynamic behavior.

Rope System Implementation

To allow blocks to hang dynamically—either from the sky or other blocks—I implemented the rope mechanic as a service, ensuring flexibility and scalability across levels.

I structured this system around three key interfaces:

  • IRopeRegistrar – Allows blocks to register rope endpoints, marking attachment points for rope connections.

  • IRopeBuilder – Uses all registered endpoints to construct ropes. Since each level has a unique rope configuration, previously created ropes are torn down before generating new ones.

  • IRopeRenderer – Handles the visual representation of ropes, ensuring smooth rendering within the game world.

    struct Rope
    {
        std::vector<PrimitiveBox*> _EndPoints; //Can have 2 points at max
        Fiea::Engine::Physics::FDistanceJoint* _RopeJoint{nullptr};
        void AddEndPoint(PrimitiveBox* box)
        {
            FIEA_ASSERT(_EndPoints.size() < 2);
            if (_EndPoints.size() < 2)
            {
                _EndPoints.push_back(box);
            }
        }
    };

Blocks in their json can specify a rope name, they will register with the registrar then.

Level content in json—blocks are defined and “RopeName“ attribute is used to bind the block to a rope endpoint.

Rope Building Implementation

Since each level has a unique rope setup, the rope data registered during initialization is used to construct new ropes at the start of the level.

Handling Rope Physics

One of the main challenges was ensuring that movement at one endpoint of a rope correctly propagates to the other—just as real ropes behave. To solve this, I utilized Distance Joint, a Box2D feature designed for maintaining fixed separation between two bodies. It calculates the world-space positions of anchor points and ensures that their separation remains constant, effectively simulating rope tension and movement.

Algorithm for Rope Construction

  1. Register endpoints – Collect 1–2 blocks per rope (a prerequisite for building ropes).

  2. Iterate through the RopesTable – Process each rope individually.

  3. Determine endpoints – A rope can have either:

    • 1 endpoint if hanging from the sky.

    • 2 endpoints if connecting two blocks.

  4. Compute world-space anchor points – Calculate positions for each endpoint.

  5. Configure a Distance Joint – Assign the corresponding physics bodies, ensuring realistic rope behavior.

void RopeService::BuildAllRopes()
{
    //Create rope objects and register them to object manager
    for (auto itr = _RopesTable.begin(); itr != _RopesTable.end(); ++itr)
    {
        FIEA_ASSERT(itr->second._EndPoints.size() > 0 && itr->second._EndPoints.size() <= 2);
        if (itr->second._EndPoints.size() == 0 || itr->second._EndPoints.size() > 2)
        {
            continue;
        }
        
        Fiea::Engine::Physics::PhysicsWorld* physicsWorld = Engine::ServiceMgr::Instance()->ProvideInterface<Fiea::Engine::Physics::PhysicsWorld>();
        FIEA_ERROR(physicsWorld != nullptr);
        if (itr->second._RopeJoint != nullptr)
        {
            continue;
        }
        size_t count = itr->second._EndPoints.size();
        Fiea::Engine::Physics::IPhysicsBody* body1 = nullptr;
        Fiea::Engine::Physics::IPhysicsBody* body2 = nullptr;
        b2Vec2  anchor1;
        b2Vec2  anchor2;

        if (count == 2)
        {
            PrimitiveBox* box1 = itr->second._EndPoints[0];
            anchor1 = { box1->ObjTransform.Position.x * SCREEN_TO_WORLD, box1->ObjTransform.Position.y * SCREEN_TO_WORLD };
            body1 = physicsWorld->GetBody(box1->_PhysicsHandle);

            PrimitiveBox* box2 = itr->second._EndPoints[1];
            anchor2 = { box2->ObjTransform.Position.x * SCREEN_TO_WORLD, box2->ObjTransform.Position.y * SCREEN_TO_WORLD };
            body2 = physicsWorld->GetBody(box2->_PhysicsHandle);

        }
        else
        {
            PrimitiveBox* box = itr->second._EndPoints[0];

            // block's world anchor
            anchor2 = {
                box->ObjTransform.Position.x * SCREEN_TO_WORLD,
                box->ObjTransform.Position.y * SCREEN_TO_WORLD
            };
            body2 = physicsWorld->GetBody(box->_PhysicsHandle);

            // top-of-screen anchor: same X, Y = 0 (pixel 0 -> world 0)
            anchor1 = { anchor2.x, 0.0f };

            // create a static body at anchorA
            b2BodyDef bd;
            bd.type = b2_staticBody;
            bd.position = anchor1;
            auto staticBody = physicsWorld->CreateBody(&bd);
            body1 = staticBody;
        }
   
        b2DistanceJointDef dj;
        dj.bodyA = body1->GetRawPhysicsBody() ;
        dj.bodyB = body2->GetRawPhysicsBody();

        dj.localAnchorA = body1->GetLocalPoint(anchor1);
        dj.localAnchorB = body2->GetLocalPoint(anchor2);
        dj.length = (anchor2 - anchor1).Length();

        dj.frequencyHz = 0.0f;    // zero = rigid
        dj.dampingRatio = 0.0f;

        Engine::Physics::FDistanceJoint* ropeJoint = (Engine::Physics::FDistanceJoint*)physicsWorld->CreateJoint(&dj);
        itr->second._RopeJoint = ropeJoint;
    }
}

That was it! I’m proud of the product that me and my team were able to produce. If you are interested in checking out the trailer of our game or if you want to play it, please go to the game page.

Next
Next

A Philosophy of Craft: Abstractions and Entropy in Software Engineering