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. TheUpdate
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:
Calls the Prefab service, which holds the current bullet prefab.
Applies properties such as speed, radius, and damage from the player's scope to the bullet.
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:
Retrieves the physics handle of the bullet.
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
Register endpoints – Collect 1–2 blocks per rope (a prerequisite for building ropes).
Iterate through the
RopesTable
– Process each rope individually.Determine endpoints – A rope can have either:
1 endpoint if hanging from the sky.
2 endpoints if connecting two blocks.
Compute world-space anchor points – Calculate positions for each endpoint.
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.