Hello! My name is Sergey Kachan, and I’m a client developer on the War Robots project. War Robots has been around for many years, and during this time the game has accumulated a huge variety of content: robots, weapons, drones, titans, pilots, and so on. And for all of this to work, we need to store a large amount of different types of information. This information is stored in “balances.” Today I’m going to talk about how balances are structured in our project, what’s happened to them over the past 11 years, and how we’ve dealt with it. Balances in the Project Like any other project, War Robots can be divided into two parts: meta and core gameplay. Meta gameplay (metagaming) is any activity that goes beyond the core game loop but still affects the gameplay. This includes purchasing and upgrading game content, participating in social or event activities. Meta gameplay (metagaming) Core gameplay (core gameplay loop) is the main repeating cycle of actions that the player performs in the game to achieve their goals. In our case, it’s robot battles on specific maps. Core gameplay (core gameplay loop) Each part of the project needs its own balance, so we also split balances into two categories — meta and core. War Robots also has so-called Skirmish modes, which require their own separate balances. Skirmish modes A Skirmish mode is a modification of existing modes or maps with different characteristics or rules. Skirmish modes are often event-based, available to players during various holidays, mainly for fun. For example, players might be able to kill each other with a single shot or move around in zero gravity. Skirmish mode So in total, we have 4 balances: 2 for the default mode and 2 for the Skirmish mode. Over 11 years, War Robots has accumulated a ton of awesome content: 95 robots 21 titans 175 different weapons 40 drones 16 motherships a huge number of skins, remodels, modules, pilots, turrets, ultimate versions of content, and maps 95 robots 21 titans 175 different weapons 40 drones 16 motherships a huge number of skins, remodels, modules, pilots, turrets, ultimate versions of content, and maps And as you can imagine, to make all of this work we need to store information about behavior, stats, availability, prices, and much, much more. As a result, our balances have grown to an indecent size: Default mode Skirmish mode Meta balance 9.2 MB 9.2 MB Core balance 13.1 MB 13.1 MB Default mode Skirmish mode Meta balance 9.2 MB 9.2 MB Core balance 13.1 MB 13.1 MB Default mode Skirmish mode Default mode Default mode Default mode Skirmish mode Skirmish mode Skirmish mode Meta balance 9.2 MB 9.2 MB Meta balance Meta balance Meta balance 9.2 MB 9.2 MB 9.2 MB 9.2 MB Core balance 13.1 MB 13.1 MB Core balance Core balance Core balance 13.1 MB 13.1 MB 13.1 MB 13.1 MB After some quick calculations, we found that a player would need to download 44.6 MB. That’s quite a lot! 44.6 MB We really didn’t want to force players to download such large amounts of data every time a balance changed. And distributing that much data via CDN isn’t exactly cheap either. Just to remind you: War Robots has reached 300 million registered users. In 2024, our monthly active audience was 4.7 million people, and 690 thousand players logged in every day. 300 million registered users 4.7 million people 690 thousand players Now imagine the amount of data. A lot, right? We thought so too. So, we decided to do everything we could to cut down the size of our balances! Hunting Down the Problem The first step was to analyze the balances and try to figure out: “What’s taking up so much space?” Manually going through everything was the last thing we wanted to do — it would’ve taken ages. So, we wrote a set of tools that collected and aggregated all the information we needed about the balances. The tool would take a balance file as input and, using reflection, iterate through all the structures, gathering data on what types of information we stored and how much space each one occupied. The results were discouraging: Meta Balance % in balance Usage count String 28.478 % 164 553 Int32 27.917 % 161 312 Boolean 6.329 % 36 568 Double 5.845 % 33 772 Int64 4.682 % 27 054 Custom structures 26.749 % — % in balance Usage count String 28.478 % 164 553 Int32 27.917 % 161 312 Boolean 6.329 % 36 568 Double 5.845 % 33 772 Int64 4.682 % 27 054 Custom structures 26.749 % — % in balance Usage count % in balance % in balance % in balance Usage count Usage count Usage count String 28.478 % 164 553 String String String 28.478 % 28.478 % 164 553 164 553 Int32 27.917 % 161 312 Int32 Int32 Int32 27.917 % 27.917 % 161 312 161 312 Boolean 6.329 % 36 568 Boolean Boolean Boolean 6.329 % 6.329 % 36 568 36 568 Double 5.845 % 33 772 Double Double Double 5.845 % 5.845 % 33 772 33 772 Int64 4.682 % 27 054 Int64 Int64 Int64 4.682 % 4.682 % 27 054 27 054 Custom structures 26.749 % — Custom structures Custom structures Custom structures 26.749 % 26.749 % — — Core Balance % in balance Usage count String 34.259 % 232 229 Double 23.370 % 158 418 Int32 20.955 % 142 050 Boolean 5.306 % 34 323 Custom structures 16.11 % — % in balance Usage count String 34.259 % 232 229 Double 23.370 % 158 418 Int32 20.955 % 142 050 Boolean 5.306 % 34 323 Custom structures 16.11 % — % in balance Usage count % in balance % in balance % in balance Usage count Usage count Usage count String 34.259 % 232 229 String String String 34.259 % 34.259 % 232 229 232 229 Double 23.370 % 158 418 Double Double Double 23.370 % 23.370 % 158 418 158 418 Int32 20.955 % 142 050 Int32 Int32 Int32 20.955 % 20.955 % 142 050 142 050 Boolean 5.306 % 34 323 Boolean Boolean Boolean 5.306 % 5.306 % 34 323 34 323 Custom structures 16.11 % — Custom structures Custom structures Custom structures 16.11 % 16.11 % — — After analyzing the situation, we realized that strings were taking up far too much space, and something had to be done about it. strings were taking up far too much space So, we built another tool. This one scanned the balance file and generated a map of all the strings along with the number of times each one was duplicated. The results weren’t encouraging either. Some strings were repeated tens of thousands of times! We had found the problem. Now the question was: how do we fix it? Optimizing the Balances For obvious reasons, we couldn’t just get rid of strings altogether. Strings are used for things like localization keys and various IDs. But what we could do was eliminate the duplication of strings. The idea was as simple as it gets: Create a list of unique strings for each balance (essentially, a dedicated storage). Send this list along with the data. Create a list of unique strings for each balance (essentially, a dedicated storage). unique strings Send this list along with the data. public class BalanceMessage { public BalanceMessageData Data; public StringStorage Storage; public string Version; } public class BalanceMessage { public BalanceMessageData Data; public StringStorage Storage; public string Version; } StringStorage is essentially a wrapper around a list of strings. When we build the string storage, each balance structure remembers the index of the string it needs. Later, when retrieving data, we just pass the index and quickly get the value. public class StringStorage { public List<string> Values; public string GetValue(StringIdx id) => Values[id]; } public class StringStorage { public List<string> Values; public string GetValue(StringIdx id) => Values[id]; } Instead of passing the strings themselves inside the balance structures, we began passing the index of where the string is stored in the string storage. Before: public class SomeBalanceMessage { public string Id; public string Name; public int Amount; } public class SomeBalanceMessage { public string Id; public string Name; public int Amount; } After: public class SomeBalanceMessageV2 { public StringIdx Id; public StringIdx Name; public int Amount; } public class SomeBalanceMessageV2 { public StringIdx Id; public StringIdx Name; public int Amount; } StringIdx is basically just a wrapper around an int. This way, we completely eliminated direct string transfers inside the balance structures. public readonly struct StringIdx : IEquatable<StringIdx> { private readonly int _id; internal StringIdx(int value) {_id = value; } public static implicit operator int(StringIdx value) => value._id; public bool Equals(StringIdx other) => _id == other._id; } public readonly struct StringIdx : IEquatable<StringIdx> { private readonly int _id; internal StringIdx(int value) {_id = value; } public static implicit operator int(StringIdx value) => value._id; public bool Equals(StringIdx other) => _id == other._id; } This approach reduced the number of strings by tens of times. String usage count String usage count Before After Meta balance 164 553 10 082 Core balance 232 229 14 228 String usage count String usage count Before After Meta balance 164 553 10 082 Core balance 232 229 14 228 String usage count String usage count String usage count String usage count String usage count String usage count String usage count String usage count Before After Before Before Before After After After Meta balance 164 553 10 082 Meta balance Meta balance Meta balance 164 553 164 553 10 082 10 082 Core balance 232 229 14 228 Core balance Core balance Core balance 232 229 232 229 14 228 14 228 Not bad, right? But that was just the beginning — we didn’t stop there. Reworking the Data Protocol For transmitting and processing balance structures, we had been using MessagePack. MessagePack MessagePack is a binary data serialization format designed as a more compact and faster alternative to JSON. It’s meant for efficient data exchange between applications or services, allowing a significant reduction in data size — especially useful where performance and bandwidth matter. Initially, MessagePack came in a JSON-like format, where the data used string keys. That’s certainly convenient, but also quite space-consuming. So we decided to sacrifice some flexibility and switch to a binary byte array. string keys binary byte array Before: public class SomeBalanceMessage { [Key("id")] public string Id; [Key("name")] public string Name; [Key("amount")] public int Amount; } public class SomeBalanceMessage { [Key("id")] public string Id; [Key("name")] public string Name; [Key("amount")] public int Amount; } After: public class SomeBalanceMessageV2 { [Key(0)] public StringIdx Id; [Key(1)] public StringIdx Name; [Key(2)] public int Amount; } public class SomeBalanceMessageV2 { [Key(0)] public StringIdx Id; [Key(1)] public StringIdx Name; [Key(2)] public int Amount; } We also removed all empty collections — instead of sending them, we now transmit null values. This reduced both the overall data size and the time required for serialization and deserialization. Testing the Changes A golden rule of good development (and one that will save you a lot of nerves) is to always implement new features in a way that lets you quickly roll them back if something goes wrong. For that reason, we add all new features behind “toggles.” To make this work, we had to support two versions of balances at the same time: the old one and the optimized one. During development, we needed to make sure that all data was transferred correctly. Old and new balances — regardless of format or structure — had to produce the exact same values. And remember, the optimized balances had changed their structure drastically, but that wasn’t supposed to affect anything except their size. To achieve this, we wrote a large number of unit tests for each balance. At first, we compared all fields “head-on” — checking every single one explicitly. This worked, but it was time-consuming, and even the smallest change in the balances would break the tests, forcing us to rewrite them constantly. This slowed us down and was quite distracting. Eventually, we had enough of that and came up with a more convenient testing approach for comparing balances. Reflection came to the rescue again. We took two versions of the balance structures, e.g. SomeBalanceMessage and SomeBalanceMessageV2, and iterated over them — comparing field counts, names, and values. If anything didn’t match, we tracked down the problem. This solution saved us a huge amount of time later on. Optimization Results Thanks to these optimizations, we managed to reduce both the size of the files transmitted over the network and the time it takes to deserialize them on the client. We also decreased the amount of memory required on the client side after balance deserialization. File Size Old balances Optimized balances Profit Meta balance 9.2 MB 1.28 MB - 86 % Core balance 13.1 MB 2.22 MB - 83 % Old balances Optimized balances Profit Meta balance 9.2 MB 1.28 MB - 86 % Core balance 13.1 MB 2.22 MB - 83 % Old balances Optimized balances Profit Old balances Old balances Old balances Optimized balances Optimized balances Optimized balances Profit Profit Profit Meta balance 9.2 MB 1.28 MB - 86 % Meta balance Meta balance Meta balance 9.2 MB 9.2 MB 1.28 MB 1.28 MB - 86 % - 86 % Core balance 13.1 MB 2.22 MB - 83 % Core balance Core balance Core balance 13.1 MB 13.1 MB 2.22 MB 2.22 MB - 83 % - 83 % Deserialization Time Old balances Optimized balances Profit Meta balance 967 ms 199 ms - 79 % Core balance 1165 ms 265 ms - 77 % Old balances Optimized balances Profit Meta balance 967 ms 199 ms - 79 % Core balance 1165 ms 265 ms - 77 % Old balances Optimized balances Profit Old balances Old balances Old balances Optimized balances Optimized balances Optimized balances Profit Profit Profit Meta balance 967 ms 199 ms - 79 % Meta balance Meta balance Meta balance 967 ms 967 ms 199 ms 199 ms - 79 % - 79 % Core balance 1165 ms 265 ms - 77 % Core balance Core balance Core balance 1165 ms 1165 ms 265 ms 265 ms - 77 % - 77 % Data in Memory Old balances Optimized balances Profit Meta + Core ~ 45.3 MB ~ 33.5 MB - 26 % Old balances Optimized balances Profit Meta + Core ~ 45.3 MB ~ 33.5 MB - 26 % Old balances Optimized balances Profit Old balances Old balances Old balances Optimized balances Optimized balances Optimized balances Profit Profit Profit Meta + Core ~ 45.3 MB ~ 33.5 MB - 26 % Meta + Core Meta + Core Meta + Core ~ 45.3 MB ~ 45.3 MB ~ 33.5 MB ~ 33.5 MB - 26 % - 26 % Conclusions The results of the optimization fully satisfied us. The balance files were reduced by more than 80%. Traffic went down, and the players were happy. To sum it up: be careful with the data you transmit, and don’t send anything unnecessary. Strings are best stored in unique storages to avoid creating duplicates. And if your custom data (prices, stats, etc.) also contains a lot of repetition, try packing those into unique storages as well. This will save you many megabytes — and a lot of money on maintaining CDN servers.