The app that went quiet in Arabic
A player wrote in on Discord: no gank warnings, all game. Not one.
My first guess was the usual one. Scoutr.gg watches the minimap, and when it says nothing it's almost always because it's looking at the wrong corner of the screen. I went through that checklist and everything was fine. The app was looking at the right pixels. It just wasn't looking for anyone.
Then I opened the server logs for that game and the champion names were in Arabic.
A name is not an identifier
League's local API tells the app who is in the match. Each player comes with a
championName, and I had been treating that string as the champion's identity: look it up,
get the asset key, fetch the portrait, hand it to the minimap tracker.
It turns out championName is a display name. It's written in whatever language the
player has set their League client to.
My lookup table knew two languages, English and Spanish, because those are the two the app
itself speaks. For anything else it fell back to "strip everything that isn't a letter and
hope what's left is the key". That fallback only knew the letters A to Z. Run it on 세트
and you get an empty string.
Everything downstream took that empty string politely:
No portrait means no template to match against the minimap. No template means the tracker follows nobody. No sightings means no gank alerts, no spoken callouts, no heatmap. And nothing in the app flagged it, because from the tracker's side nothing was wrong. It had been given an empty list of people to find, and it found all of them.
The cloud side failed the same way, more quietly still. The AI coach looks up scouting data, matchup notes and builds by champion name. All of those lookups missed, and the coach was left to work with names it couldn't connect to anything it knew.
How big was it?
Once I knew what to look for, I ran the old lookup against every language the game ships in.
This chart explains why nobody noticed for months, me included. Most of Europe plays with names that are spelled exactly like the English ones. A German or Thai client worked perfectly, by coincidence. A French client lost five champions out of 173 (Zoé, Séraphine, Maître Yi and friends), which you'd never spot unless one of them happened to be the enemy jungler. And then there's a cliff: seven languages where the app recognised nobody at all.
Partial failures get reported. Total ones, apparently, get uninstalled.
The fix was already in the payload
The same API response carries a second field per player that I had never used:
{
"championName": "세트",
"rawChampionName": "game_character_displayname_Sett"
}
That suffix is the game's internal key for the champion, and it doesn't change with the client language. Chemistry has the same problem and solved it long ago: iron is hierro, железо or 鉄 depending on who you ask, and that's why labs write Fe. I had been indexing my shelf by what people call things.
There were two ways to use it.
The obvious one was to teach every consumer about the key: the tracker, the coach prompts, the scoreboard, the champ-select screen. That's a lot of call sites, each with its own idea of what a champion name is.
I went with the other one. A single step, right where the payload enters the app, rewrites every champion name to its canonical English form before anyone else sees it. After that line, a Korean client and an English client are indistinguishable to the rest of the program. Nothing downstream changed, and nothing downstream can get it wrong again, because nothing downstream ever sees a localised name.
English, not the player's language, because everything on the server is keyed in English already. One language in the middle, translation at the edges.
Two details I'm glad I slowed down for:
- An English client gets back the same object it sent. Not an equal copy. The step is a no-op unless there's something to rewrite, so the path every existing user is on didn't change by a byte.
- Spells and runes are only rewritten when they'd otherwise be unknown. Some of them have upgraded variants that other code matches by exact name, and a well-meaning normaliser would have flattened those.
It surprised me twice
The test I almost ran proves nothing. My first instinct was to switch my own client to French and play a game. Look at the chart again: French would have passed before the fix. I tested with a Korean client on a recorded game instead. If a test can't fail on the old code, it isn't a test of the new code.
There are two spellings of the key. The day after shipping, a Korean player's logs showed two champions still unresolved. For most of the roster the raw field looks like the example above. For a few it looks like this:
Character_Seraphine_Name
Same idea, different wrapper, no documentation I could find. The only reason I caught it within a day is a change I made in the same fix: an unresolved champion is now a logged warning with the raw string in it, instead of an empty string that looks like success. The first version of this bug was invisible for months. The second lasted a day.
Where it hurts
- The app understands your language. It doesn't speak it. A Korean player now gets tracking, alerts and coaching, with champion names in English. Translating the app itself is a different, much larger job, and I haven't done it.
- Spanish got slightly worse. Because the middle is English, Spanish players now see Bard and Master Yi where they used to see Bardo and Maestro Yi. I took that trade knowingly; I'm not thrilled about it.
- I found two formats. I can't promise there isn't a third. The warning is my only defence, and it only reaches me from players who share diagnostics.
- Even the internal key lies sometimes. Neeko can disguise herself as another champion, and while she does, the game reports her as that champion, internal key included. Anything that has to survive a whole game is now keyed on the player, not on whatever they currently look like.
While I was in there I also found that the app's own language detection had never worked. It was looking for the setting in the wrong place, and had been quietly defaulting to English for everyone. Same family of bug: a fallback so reasonable that nothing ever complained.
That's the part I keep coming back to. Every piece of this did the sensible thing with bad input. The lookup returned an empty string instead of raising. The tracker accepted an empty roster. Nothing downstream had a reason to object. Each default was defensible on its own, and together they made a product that failed completely for a whole group of players without a single error to show for it.
If you play League in a language I haven't thought about and something looks off, tell me on Discord — that's how this one got found. I'm Manu; I'm building Scoutr.gg solo and writing up the engineering as I go.