Build insane, fully customisable robot battle vehicles that drive, hover, walk and fly in the free-to-play action game Robocraft. Add weapons from the future and jump in the driving seat as you take your creation into battle against other players online!
Robocraft 2 is a multiplayer game built on a client-server architecture, featuring a unique server-side physics system and highly customizable player creations.
During my time at Freejam, I utilized Unity's Job System to write efficient multithreaded code, improving performance and ensuring smooth gameplay across various systems. I worked closely with the team to implement solutions that scaled well, taking full advantage of Unity’s parallel processing capabilities. Additionally, I used Perforce (P4V) for version control to ensure streamlined collaboration and proper management of our game’s assets and codebase. For project management, I employed Jira and Confluence to support our Agile workflow, track tasks, and maintain detailed game design documents and technical documentation, ensuring that the team stayed aligned and on schedule throughout the development process. I also contributed to the development of various tools to streamline workflows, improve game builds, and assist the team in creating, testing, and deploying features more efficiently.
As part of the full release of Robocraft 2, we planned to include various PvE gamemodes for players to enjoy in a CO-OP way. These gamemodes differed from our normal PvP bu having increased AI count, specially created objectives and reworked gameplay systems.
Visual Scripting:
To implement Visual Scripting in our Unity project, we decided to use Unity's built-in solution (the Visual Scripting package based on the Bolt package). With this package, I could create custom units (nodes) that designers could use to create gameplay logic. However, before this could happen, I had to integrate our framework (Svelto ECS) to allow the use of systems within these custom units.
To do this, I used interfaces to inject the necessary systems into the units. Since the graphs would be placed on a GameObject in the scene, I reacted to the scene being created and then searched for a tagged component. This component allowed me to iterate through the child GameObjects and find all the graphs. For each graph, I gathered all the units within it (in the case of nested graphs, I recursively iterated through the units to find all of them) and injected any required fields based on the interfaces they implemented. This approach allowed me to query, create, remove, and manipulate data of any entity without the need to write a dedicated engine (ECS system) for it.
I also had to create custom event units for gameplay events, such as player death or damage. More importantly, to keep the logic in sync with our engines, I called the update event on the deterministic frame during which our gameplay logic ran. If a unit needed to perform logic that had to be executed at a specific timing (e.g., raycasting, which ran on a synchronized frame with DOTS to ensure all physics entities were included), it could use ECSRequests. ECSRequests allow you to create a request that will be processed by a dedicated engine to perform an action based on the request data. You can poll a request to check if it is done, so units with such requests are run as coroutines, as a request can take multiple frames to complete. One example of ECSRequest usage was checking if a position was in sight of enemy team players. I would simply create a request, supply the float3 position to check, and wait to see if the result was true or false.
PvE Objectives:
Creating the Visual Scripting system served as the foundation for gameplay objectives that designers could set up. I created the first four challenges based on the provided design documents in Confluence, working closely with the design team. Each objective was a unique entity, but shared a base descriptor with logic that was common across them (e.g., base details about the objective type, whether it is active, etc.). This allowed the objectives to be created using their own factory, which was injected as needed into a custom unit.
The attack and defend objectives were new physics entities that would be spawned in the world using a custom unit. Since these objectives were essentially structures that could be damaged, had health, and a team value, they functioned the same way (defend objectives for your team, attack objectives for the enemy team). These entities had a rigidbody and could be set as static or moved via a custom unit to adjust their position. They would also animate upon activation. However, because the graph was server-side and the object itself was rendered on the client, I created a simple data-driven animation system to allow server-side animation of the object. This system would use simple animation frames to lerp the position and rotation of the object. The benefit of this approach is that the colliders also move with the object, so it's not just a cosmetic animation.
The extermination objective simply set a goal for the required number of enemies to eliminate. It could be set as a fixed number or be based on the currently alive enemies from the enemy team. Due to the simplicity of this objective, progress tracking was implemented using an engine (with the ability to change progress via a unit), which would automatically decrease the remaining count when a player died.
The escort objective, by itself, was a parent entity that held information about the escort target, which was simply an ally AI player machine with a custom behavior tree. It would also contain developer-only blocks to make it appear different from other machines.
Documentation:
This task of creating visual scripting system and PvE objectives was originally only created by me and Gabriel Keny (Senior Designer) I had to ensure anyone who would be added to this task will be able to quickly learn the workflow and know how to add more features and units to the project.
To achieve this, I wrote extensive documentation on how to create new custom units, add new interfaces to inject data into these units, best practices to follow, and quirky features or problems I encountered, so others could avoid the same mistakes and issues. Additionally, I documented how each custom unit I created worked, including their inputs, outputs, and modifiers, as well as what they do. This documentation proved invaluable when we hired a technical designer to create base systems for us to expand on.
All documentation was written in Confluence.
Optimisations:
When testing how the game runs in a PvE setting I noticed that the way we handle current destruction was very inneficient and created big lag spikes. As the PvE gamemode was focused on battles with more machines than a player would encounter ine PvP this was a big problem. The issue was caused by technical limitations of Svelto ECS and it's usage of entity groups. We use those groups as states, so if a block was destroyed its group was swapped to a destroyed group. With small entities that don't have many components this is a fast operation but in RB2 blocks were the most complex entity we had, with many many components. In addition to the amount of block entities being destroyed at once the group swap took a very long time (up to 150ms). All components on that entity had to be moved to another dictionary, per entity, which greatly increased the time. This action happened on the server, and then was sent to the client to be repeated.
To solve this issue I removed the group swap on block entities. Which sounds easy but it caused another problem, how will engines react on a group swap which doesn't exist anymore? Well I created a container which held information of all destroyed and not destroyed blocks as a hashset, with two dictionaries for "group swap" callbacks. Now any engine which had to react on a block swapping groups would register to a class that would invoke a method providing the blocks that swapped groups that frame. It would provide the group of that block (for engines that only cared for a specific block like a seat or a gun for example) alongside an array with those block indices. They could then query components and use those indices to only iterate those blocks.
To keep the timing of those callbacks as close as they were with traditional Svelto "IReactOnSwap" callbacks, I would gather all swapped entities at the very end of the deterministic frame after all engines ran and invoke the callbacks. This worked perfectly, allowing for massive destruction with increased performance, the new callbacks took up to 15-20ms which also included creation of cosmetic blocks from the destruction which were another big performance bottleneck. Lastly we had to think about the networking side of this solution, as all swaps were synced to the client in out framework I had to react to these callbacks on the server and sync each entity to the client. All I had to sync was a "isDestroyed" boolean which is only a byte and we saw that it was much better in terms of data sent than a group swap. The client would run the same logic as the server but only by iterating all entities that were network synced that frame (another hashset I had to add an inject into the engine as previously we had no need to iterate only synced entities).
To conclude this removed one of the biggest lag spikes in our game by reducing the destruction frame time from around 150ms to 15ms on a big T10 robot (T10 is the biggest machine one can create in RB2).
Another issue I encountered when testing PvE was with creation of AI. Creating a machine takes a long time, to deserialize a T10 machine it takes over 200ms not including addition of those entities into the database. In PvP we cannot do much about this process, each player machine is different and therefore we can't reuse those entities. But in PvE AI players often use the same machine. So I decided to pool machines that will be reused by other AI players. I can check if our machine cach data (containing pure array of byte data of the machine) already has the machine, this means we can check if there are any avalible machine in the pool for the player to use that match that machines FactoryID (unique ID based on the machine data, so two machines with the same byteData would have the same factoryID).
First approach was to swap the machine entity group into a pool group (because it's a simple entity this swap is very fast). I would then iterate through all of the entities belonging to that machine (using a filter) and swap the rigidbody, connections (entities representing block connections) and the block entities into their own pool group. When reassinging the machine I would iterate through those entities, assign the new owning player and swap the group back to the entity original group.
Second approach: As I mentioned before block entity swaps are very expensive on a large scale, so my second approach removed that swap, instead each block would be destroyed instead (using previously mentioned optimization). In addition to that I could not swap machine connections as they are based on the blocks which would be destroyed so we could avoid that swap. This worked fairly well, the pooling worked reducing the 200ms deserialization time to a simple quick reassignment taking around 17ms .
Last solution: The only problem remaining was the fact that engines which needed to iterate through blocks had to check if a block was destroyed in order to skip performing logic on that entity. This is fine if we have a set 20 machines in the game at one time (PvP). But when we put 99 T10 Machines into the pool (stress test, not an actual requirement for pooled machine count), those engines take longer to iterate through entities, instead of iterating 10 blocks it will include the remaining 900 ones that are destroyed because they are waiting to be reused, this would add at least 8ms per frame. To avoid that the container created for holding destroyed blocks has a dictionary containing all active blocks. This way an engine can request to get the indices of active blocks in that specific group of entities and only iterate through those indices. This is a readonly list of indices, the only place it can be modified is the container itself. This solution fixed all the issues, where we finally could deactive a block entity and it would not be iterated on by any engine. Thanks to this we can reuse machines and the creation time was insignificant. The next step for this was to wrap the svelto query in the container to make it easier for people to write code but sadly I did not have time for the last improvement.
I created the scoreboard for Robocraft 2 Legacy and Redux. With both the code implementation to gather player scores and the UI prefab integration and functionality.
The scoreboard UI data is supplied by a GUI engine created to sync entity data, using SveltoGUI to inject the WidgetDataSource dictionary for the scoreboard with the correct data, including scores and player cosmetics. When a player joins the game, a scoreboard panel entity is created for them. This panel contains the player's username, avatar, frame, and banner data, as well as their score. As per design, when a player leaves, their data is locked on the scoreboard and slightly greyed out to indicate that the player is no longer connected to the server. Each score category can be used to sort the scoreboard in both ascending and descending orders, allowing players to customize it for their needs during gameplay.
Most importantly, how do we sync each player's score data without sending too much data over the network each frame? To solve this, I created a custom network message. The player scores were separated into byte scores (full number scores that would not exceed 255, such as kills and objectives) and ushort scores (less precise yet more detailed scores, like damage and healing). Since we want to limit the amount of data, we use bytes and ushorts instead of ints and floats.
The message contains two hashmaps: one mapping playerID to "headerValue." These maps indicate which player's score in a given category has changed. For example, if Player 1's healing and damage scores changed, the value of the ushort dictionary would reflect that, based on the bit-shifting done to that value, which corresponds to the enum category of the ushort scores. This way, only one ushort (or byte, depending on the number of categories) needs to be sent per player to indicate which scores have changed.
The message also contains two hashmaps: one mapping playerID to another dictionary of "scoreCategory" to value. This allows us to read the changedScore hashmap and determine if we need to read/write the second hashmap containing the actual score values. By doing this, we only send the changed scores to other players.
In addition to only sending the changed score values (and not all of them), we limit the message to be sent every second, not every frame, and only if scores have changed. The only exception is when sending a full score message to players who join the game server late, as they need all the score data. The creation of these messages is also put into a job and burst-optimized. I used native containers to hold the data, specifically NativeHashMap and SharedSveltoDictionaryNative. This ensures the message is created quickly, serialized/deserialized, and read extremely fast. It is also small, minimizing its impact on the network.
Block Forge is a currency exchange terminal where players can create and recycle blocks based on their recipes and resources.
The core of the Block Forge is the BlockForgeResourceManager, which holds and manages everything the player needs. When initialized, it requests the player's recipes from the backend and caches them. Every time the player opens the Forge, if the cache is dirty, it will request the data again. This approach helps limit the number of requests to the backend. The recipe data is structured in a way that is easy for me to read and for our backend programmer to organize. Each player has recipes for currencies with multiple requirements and returns. This allows the same request to be used to either "purchase" a block, create a block from resources, or destroy a block to receive resources. We call this the Atomizer Mode.
Because we cache the player's backend data to reduce request frequency, we can verify on the client whether the player has enough currency to buy a block. The backend request does the same, ensuring that players cannot modify client data and purchase everything. This added layer allows the backend to only do work when absolutely necessary.
Each slot in the Forge is created dynamically based on the recipe data and uses SveltoGUI. This way, I can modify the GUI data without having to recreate each slot every time the amount of a block in the player inventory changes. I created many commands that are attached to the GUI prefabs, which determine what block or currency is added or removed from the basket. Additionally, the Atomizer Mode (triggered by the toggle in the top-right corner) is separated from the default Forge, allowing players to modify both baskets independently. To improve the performance of this screen, the two lists of slots are also separated, which reduces the time required to load data and prefabs.
Lastly, this manager also handles setting the correct prefab data to display the currently selected slot holograms. A separate engine manages the animation in the background, sending the currently crafted blocks across the Forge. This allows players to craft more blocks, which are added to the animation queue. When switching the Forge mode to Atomizer, I change the animation state to "red."
I was asked to figure out how we can place spawn points in the scene which will spawn players in the game. We already had spawn point entities but now we had a different way of creation maps in the game.
Creating an entity is split into two parts. First is the creation of the core entity, which will return an EntityInitializer that can be used to initialize the components of that entity with the correct data. To handle this, I created an EntityConverterBase with abstract BuildEntity and RemoveEntity functions. This allows anyone to create a class that inherits from the converter and specify which entity they want to build. To fill the component data, I created another abstract class called SveltoComponentBaker, which has a single function Bake, to which I pass the EntityInitializer of the entity. Now, the converter has a list of these bakers. We can create a prefab, add the EntityConverter component, along with any baker we want. It could be a generic transform baker that fills in the position and rotation components, or something more specific to that entity. Each baker is assigned to the list of bakers on the converter.
How do we call all these abstract methods? Well, I wrote an engine that reacts when a scene is loaded. It iterates through all objects with the EntityConverterBase class. If it finds one, it calls the BuildEntity function and iterates through the list of bakers, calling the Bake function on each one. This setup also allows us to create custom interfaces that can be used to inject specific data into the bakers. For example, IPropPhysicsBaker will inject the PhysicsResourceManager into the baker before the Bake function is called.
Cleanup is very important that's why we have the RemoveEntity abstract function on each EntityConverter. When a scene is unloaded we iterate again through each Converter and remove that entity that was created.
Lastly I created a Client and Server versions of both the converters and engines which call the functions, this way we can either create a server only entity or client only. Or both!
Rectify is a simple ability that repositions a player's machine upright on a short cooldown. I've made two versions of this ability. The first one is fully physics-based, flipping the player's machine in any free direction so that it always lands on its wheels. Using a combination of box casts, the system checks which direction is free and unobstructed. If no direction is clear, it will flip the machine in place and apply just enough force to rotate it (Video 1). The second version applies a kinematic force that lifts the machine, rotates it, and gently places it back down. Both versions are data-driven, allowing designers to adjust the forces, timing, and rotations as needed.
Weapon Switching works by selecting only one weapon bank as the active bank. This is done by having a currentlySelectedBankIndex value on the machine, which can be changed by selecting one of the three available weapon banks (you can only have three weapon banks). When a bank is selected, the value of currentlySelectedBankIndex is updated. Weapons from inactive banks have their lookAt point set at a constant forward distance from the machine's forward vector to make them appear inactive. Any bank that is not selected does not receive input values, so only the active weapon bank's weapons can read the fire/aim inputs.
I've also implemented the UI for this feature, which dynamically reads all weapon banks and creates the bank icons on the player HUD. The icon for each bank is the highest tier weapon in that bank. The selected bank's icon is highlighted, and if a bank has no active weapons, the icon is greyed out.
Lastly, I implemented a system to assign a crosshair to a weapon in the data, which is displayed when selecting that weapon. Each crosshair can have its own custom implementation without conflicting with other weapon types. Additionally, in the Legacy version of the game, I implemented "aimpoints"—circles that show where weapons are aiming in the world, similar to War Thunder.
I could write much more about the other features I've created while working on Robocraft 2, and I would be more than happy to discuss them in detail upon request!