In September 2024, War Robots support received a surge of complaints from Chinese iOS players: after updating to iOS 18, in-game text was displaying incorrectly for some users. The issue manifested differently across devices: some players noticed missing characters, while others reported wrong or unfamiliar glyph styles. To make matters worse, we were unable to reproduce the bug on our local test devices.
My name is Sergey Madekin, Senior Developer on War Robots. In this article, I will share how we ultimately resolved the missing Chinese characters issue.
Investigating the Root Cause
Upon investigation, we discovered that our Chinese text rendering relied directly on system fonts. The game uses standard Unity UI (Text) components configured with dynamic fonts. Unity first searches for required glyphs within the designated font asset, then moves through the Font Names list, and finally falls back to the engine’s built-in list of fallback system fonts for the given platform. It was this fallback mechanism that produced inconsistent results across different devices running iOS 18.
A similar issue with missing characters in Legacy Text on iOS 18+ is logged in the Unity Issue Tracker. At the time of writing, no fix version is specified in the ticket.
The First Attempt: Adding the Full Font
The fix itself is straightforward: bundle a dedicated Chinese font into the build and configure it as a fallback for missing glyphs. However, a complete Chinese font file contains tens of thousands of characters. Our base Noto Sans SC TTF file weighed around 10 MB, and adding it increased the final build size by 37 MB.
We initially verified the fix by releasing a build with the full font, keeping in mind that we would optimize it later if it proved effective. The update resolved all text rendering issues with no further complaints, so I moved on to reducing the file size. Fortunately, daily communication requires only a fraction of the full character set, and a specific game requires even fewer.
Optimization and Font Subsetting with pyftsubset
The next challenge was keeping only the necessary characters in thecommand-line utility from Python’s fontTools package designed to create slimmed-down font files containing only specified glyph sets. It discards unused glyphs and metadata, drastically shrinking the file size
All that remained was identifying which characters to keep. I started with a standard frequency list of common Chinese characters and ran the subsetting tool against it. However, gaps immediately appeared — particularly in localized in-game names, and there were quite a few of them. It became clear that we needed to extract the exact character set used in our text resources. I pulled all current translations and compiled a list of every unique character found in them. I then ran pyftsubset on the source file, keeping only this set. The results were impressive: out of roughly 30,000 characters in the original font, only about 3,000 remained, dropping the TTF size from 10 MB down to around 900 KB. The resulting build size increase was just 1.24 MB.
Build Size Comparison
Edge Cases and Player Nicknames
We shipped the next release with this lightweight font. Player complaints dropped drastically, but a few remained. We discovered that certain configuration files contained unlocalized strings with rare characters. Additionally, some players reported missing characters in their custom usernames.
Because the missing character count was low, I created a dedicated supplementary list. While this resolved all known edge cases, it doesn’t guarantee full coverage for future usernames containing obscure characters. Moving forward, whenever a new missing character is reported, we simply append it to the supplementary list and regenerate the font.
Automating the Font Pipeline in Unity
Since game content updates frequently, we needed an easy, automated way to update our character set before each release.
After a few iterations, we built a custom Unity Editor tool that automates the whole process in a single click:
- Connects to the target test server hosting localization files for the upcoming release.
- Downloads the latest text resources.
- Extracts unique characters from the text and merges them with our supplementary list.
- Excludes glyphs already present in the primary game font to avoid duplication.
- Verifies character availability in the source Chinese TTF file. Missing glyphs are logged and skipped.
- (Optional) Includes the character set from the previous release if the “preserve old characters” setting is enabled.
- Runs pyftsubset on the source TTF file using the compiled character list and outputs the optimized font directly into the build.
Preserving the previous release’s character set prevents regression bugs if strings are removed from current localization files. While this causes the font size to grow slightly over time, it provides a solid balance between file footprint and backward compatibility.
The Unicode Pitfall: One Character, Two Chars
While refining the generator, we uncovered another nuance: collecting target characters using a HashSet<char> is unsafe.
In C#, strings are UTF-16 encoded. Most Chinese characters we use fit into a single char, but certain rare ones require two. Together, these two units form a surrogate pair representing a single character.
A HashSet<char> stores individual unit values without preserving pairs. For instance, two distinct rare characters might share the same high surrogate. During deduplication, that shared unit is kept only once—reducing four char values down to a set of three. When passing that corrupted set to pyftsubset as a string, neither of the original characters will be fully preserved.
This behavior is demonstrated below:
string text = "U00020000U00020001"; // Two distinct characters: 𠀀 and 𠀁
Console.WriteLine(text.Length); // 4 — char count
Console.WriteLine(new HashSet<char>(text).Count); // 3 — unique char units (corrupted)
Console.WriteLine(GetTextScalars(text).Count); // 2 — actual Unicode characters
To preserve surrogate pairs intact, we introduced the GetTextScalars method. It iterates through the string and populates a HashSet<string> with complete characters: standard ones as a single-char string, and surrogate pairs as a two-char string.
private static HashSet<string> GetTextScalars(string text)
{
var result = new HashSet<string>();
for (var i = 0; i < text.Length; i++)
{
if (char.IsHighSurrogate(text[i]) && i + 1 < text.Length && char.IsLowSurrogate(text[i + 1]))
{
result.Add(text.Substring(i, 2));
i++;
}
else
{
result.Add(text[i].ToString());
}
}
return result;
}
This safeguard protects our character set during subset preparation. Note that it is unrelated to the original iOS system font fallback issue.
