WizusLabs Engineering · Frameworks

Sixteen locales, one grammar: the localization assumptions English hides

Our games ship in sixteen languages, and not one of the defects in this post is a translation error. Every string was correct and every gate stayed green. Each fault was introduced by somebody reasoning carefully about sixteen grammars using the shape of the one he thinks in — and that shape is English.

By WizusLabs Engineering · 2026-08-03 · ~17 min read

Sixteen locales do not give you sixteen chances to notice a mistake. They give one set of grammatical assumptions sixteen places to land — and the assumptions are invisible precisely because they are true of the language the code was written in. A screen reader read a plural noun over a count of one in French and Italian. A plural selector we chose on purpose was silently rewritten into a category that means something different in Hindi. A test assertion passed on the exact outcome it existed to rule out, in seven languages, for sixty-three days. Three surfaces, one author, and the author is not carelessness.

The string that only breaks in the languages you cannot read

When a mission ends in Iron Swarm, the star rating is spoken. It has to be: the three star icons are excluded from the semantics tree, so one string is the only route a screen-reader user has to that result. In English that string is {stars} of 3 stars earned, and it carries no plural selector of any kind. It does not need one. The noun stars is governed by the invariant total, the literal 3, and the variable count sits harmlessly in front of it. Zero stars, one star, three stars — the sentence is grammatical at every value and nobody ever had to think about it.

The French and Italian translations moved the noun. They read {stars} étoiles sur 3 obtenues and {stars} stelle su 3 ottenute, which puts the counted noun next to the count that varies instead of the total that does not. At one star, that is a plural noun over a count of one, in a language where the agreement is not optional. The commit that fixed it puts the reach plainly: it fired on every one-star clear. This shipped. We are not going to attach a number to it, because none exists — nothing in our records measures how many screen-reader sessions ran in those locales, and we did not trace when the wrong agreement was introduced, so there is no honest duration to quote either.

Hindi was a third defect nobody had named. Its version of the same key had a single plural-blind form, so it was wrong in the opposite direction — not the wrong plural, but no distinction at all. The French and Italian cases had surfaced incidentally, reported by translators working on a completely different key. Hindi came to light only because the dispatch was required to render all sixteen locales at zero, one, two and three stars and give a verdict on each. The template locale is the one place this class of problem is invisible, and it is also the locale everybody reads, which is why the only reliable way to find these is to make somebody look at all sixteen on purpose.

The fix was a sentence, not a selector

The instinct, once you have diagnosed a grammatical-agreement bug, is to reach for ICU plural machinery. That is the wrong first move, and two of the three locales here prove it. French went to {stars} sur 3 étoiles obtenues and Italian to {stars} su 3 stelle ottenute. No selector, no categories, no generated branching — the counted noun was simply moved back under the governance of the invariant total, exactly where English had it all along. The sentence is now correct at zero, one, two and three because there is nothing left for a count to agree with.

The strongest evidence that this is the better shape is that we did not invent it. Eleven of the other twelve non-English locales already used the same frame, which is why they were never affected in the first place. The translators who reached for a “n out of N” construction had, without being asked, produced a string that no plural rule can break. Only two had done the natural-sounding thing and put the noun on the variable.

Hindi is where the shortcut runs out. It has no equivalent “of N” frame that can carry the noun away from the count, so restructuring was not available and a selector was the only route. That is the honest shape of the rule: a plural selector is what you use when the language will not let you move the noun, and the first question is always whether you can move the noun. Three of the sixteen locales needed a fix on this string. Exactly one of them needed the machinery.

The selector we wrote is a request the generator answers in its own vocabulary

The Hindi fix used an explicit-value selector — {stars, plural, =1{…} other{…}} — chosen on purpose, because =1 reads like an instruction about the integer one rather than a bet on a category. That is not what ships. Run flutter gen-l10n and the emitted Dart calls intl.Intl.pluralLogic(stars, locale: localeName, other: …, one: …). The literal =1 appears zero times in that locale’s generated file, against fourteen one: arms. The two ARB spellings are indistinguishable in the output.

We can say that as an identity rather than as a count, which is worth the extra sentence. For every locale we measured, the number of ARB keys carrying =1{ plus the number carrying one{ equals the number of one: arms in the generated Dart: Hindi 6 + 8 = 14, English 7 + 5 = 12, Thai 5 + 0 = 5. A bare “fourteen” would have been a claim about one file. The identity is a claim about the mapping, which is the thing actually in question.

Now the part where the popular version of this story is wrong, and it is wrong in the direction that matters. The received telling is that CLDR routing replaces your selector wholesale, so =1 is ignored and the singular arm becomes unreachable in languages with no one category. That is not what the mechanism does. Intl.pluralLogic short-circuits on the exact integer before it consults any CLDR rule — at howMany == 1 it returns the one: arm unconditionally. So =1{X} other{Y} and one{X} other{Y} behave identically at one in every single language, Thai and Chinese and Japanese and Korean and Indonesian included. Nothing is dead code there.

What the rewrite actually costs is one integer wide. Collapsing =1 into one silently widens the singular arm to cover every other value the locale’s one category admits. For Hindi that is zero, and only zero. The whole defect surface is a zero-star clear taking the singular noun — and the toolchain said nothing about it, because there was nothing to warn about: gen-l10n exited zero and both spellings are legal input.

That behaviour is live today, in six Hindi keys, and it is not a bug we are carrying. It was ruled and accepted with reasons we can still check: Hindi’s one category deliberately includes zero, so a singular at zero is that language’s own documented convention; a pre-existing sibling key already behaved identically; and deviating would have made this one string inconsistent with every other Hindi plural in the app. The only thing that was ever actually wrong was a sentence in a changelog. The prior wave’s recorded rationale claimed =1 had been chosen in order to escape the category that maps zero to the singular — which is backwards, since =1 does not escape the category at all. It described the ARB as though the ARB were the artifact. It was corrected in place twenty-eight minutes later.

And zero is not beyond reach, which is the last thing to say before moving on. The same short-circuit that fires at one fires at zero: pass a zero: argument and it is returned at zero in every locale, whatever that locale’s category rule says. In an ARB, =0{…} produces exactly that arm. The toolchain is not the obstacle here. Keep that in mind for the close, because it turns out to matter more than the mechanism does.

Hindi is not an edge case. It is a rule set with four members

The reflex, on hearing that a language puts zero in its singular category, is to file it as a Hindi quirk and move on. We checked, against CLDR’s own machine-readable plural rules rather than against intuition, and the reflex is wrong twice over. Among the sixteen locales this app ships, the group whose one category admits zero is Hindi, French, Portuguese and Vietnamese. Four members, not one.

Vietnamese is the member nobody predicts, and it is not merely similar to Hindi — it sits in the same pluralRules element, under the identical condition i = 0 or n = 1. French has its own rule, i = 0,1; Portuguese its own, i = 0..1. All four route a count of zero to the singular arm. English is i = 1 and v = 0, with a single integer sample, so zero falls to other; German shares that rule set exactly. The reason this behaviour reads as exotic is that the reader’s own language is in the group that puts zero where he expects it. There is no edge here. There is a distribution, and English sits at one end of it.

Russian is a fourth kind of trap and belongs in none of those lists. Its one condition is v = 0 and i % 10 = 1 and i % 100 != 11, which puts zero in many and pulls 21, 31 and 41 into the singular. Our Russian file happens not to be exposed — it carries no explicit-value selectors at all, twelve one{ keys and zero =1{ — but the counterfactual is instructive. The same collapse in Russian would have widened the singular arm to twenty-one, thirty-one and forty-one, which is a far louder bug than a singular at zero and a far easier one to notice. The quiet failure landed in the one language whose own convention made it defensible.

One more line worth carrying, because it is the same error at a smaller scale: pt_PT sits in the Italian rule set while pt has a rule of its own. A measurement on one is not evidence about the other, inside what looks like a single language. Whatever generalisation you are about to make across a locale set, the rule table already disagrees with it somewhere.

In English, incorrect contains correct

That sentence is the whole of the next problem, and it is a fact about words rather than about code. NeuralSpark, our other sixteen-locale app, announces the outcome of an answer on a game screen using one of three strings. In English they are correct, incorrect and partly correct. The second contains the first as a substring — not as a control token that happens to overlap a real one, but as a plain morphological consequence of building an antonym with a negative prefix. So does the third.

The third one is deliberate. When we added the partial-credit outcome, the ruling required each locale’s string to be built on the same positive root that locale already uses for a fully correct answer, so that partial credit is framed as credit rather than as failure. It worked: partly correct contains correct in sixteen of sixteen locales, by construction. German teilweise richtig on richtig. Korean 부분 정답 on 정답. Vietnamese đúng một phần on đúng. Japanese 一部正解 on 正解. A good design decision, made once, that quietly established a containment relation across the entire corpus.

The negation containment is the accidental one, and it holds in exactly seven of the sixteen: English, Spanish, French, Japanese, Portuguese, Russian and Thai. incorrecto contains correcto; 不正解 contains 正解. The other nine build the antonym from a different word altogether — Italian is corretto against errato, Chinese 正确 against 错误 — and are perfectly safe. There is no way to know which group a language is in by looking at English, and no way to know it by guessing: the two illustrations that came closest to killing this post were an Italian and a Chinese example somebody had invented from morphological plausibility, both of which turned out to exist nowhere in the corpus. Every foreign-language string in this post is a value read out of an ARB file. Not one is an illustration.

The consequence for a test follows in one step. Write the assertion “the announcement contains the word for correct” and it is satisfied by the announcement for incorrect in those seven locales, and by the announcement for partly correct in all sixteen. It fails toward pass, on precisely the outcomes it exists to distinguish. That is not a broken instrument — it runs, it reports faithfully, and the answer it gives is correct for the question it asked. The question was only ever well-formed in English.

The dates make the point sharper than the mechanism does. The correct and incorrect pair landed on 2026-05-31. The partial-credit cell landed on 2026-08-02, sixty-three days later. The hazard was live for that entire window and nothing forced anybody to look at it, because two outcomes is a small enough set to reason about in your head. It took a third member arriving to make somebody enumerate the corpus. No test went red at any point, and none could have.

We are also not going to claim our suite is clean of this. It contains fifteen containment assertions over these strings, and the honest statement is a property rather than a count: every one of them asserts the hazard instead of relying on it. Each sits in a deliberately labelled starved arm, paired inside the same test body with an equality oracle that discriminates all three outcomes. That is the difference between a suite that has been measured and one that has merely been counted — and it is close to, but not the same as, the failure in a guard nobody has seen fail, where the guard’s enumeration basis was wrong. Here the basis was complete. The predicate was wrong.

A prefix test and a suffix test partition the corpus, and neither one is sound

The obvious repair is positional. Do not ask whether the announcement contains the word for correct; ask whether it starts with it. In English that discriminates cleanly, because partly correct does not start with correct. So we measured it across all sixteen, and the result is the most elegant thing in this whole investigation.

A prefix relation holds in exactly two locales: Thai and Vietnamese, where the qualifier follows the root rather than preceding it — ถูกต้องบางส่วน on ถูกต้อง, and đúng một phần on đúng. A suffix relation holds in exactly the complementary fourteen. There is no locale anywhere in the corpus where the root sits in the middle. So the two positional tests partition the sixteen languages between them: a prefix oracle fails toward pass in two of them, a suffix oracle fails toward pass in the other fourteen, and each is perfectly sound over the part the other one breaks. Only exact equality on the isolated outcome word is sound everywhere.

Where the positive root sits inside the partial-credit announcement A schematic of one accessibility string decomposed in three locales, alongside that locale’s string for an incorrect answer. In English, “partly correct” is a qualifier followed by the positive root “correct”, so the root sits at the end and a suffix test matches while a prefix test does not; the English word for incorrect contains the positive root. In Vietnamese the order is reversed, the root comes first and the qualifier follows, so a prefix test matches and a suffix test does not; the Vietnamese word for incorrect shares no root with it. In Italian the root sits at the end as in English, but the Italian word for incorrect shares no root with the positive form. The position of the root is a property of the language rather than of the string, so no test based on position is sound across the whole set. the partial-credit announcement, decomposed solid — the positive root dashed — the qualifier, or the negation the partial-credit string the same locale’s incorrect string en partly correct root at the end — suffix matches, prefix misses incorrect contains the root vi đúng một phần root at the front — prefix matches, suffix misses sai no shared root it parzialmente corretto root at the end — suffix matches, prefix misses errato no shared root the root moves; a test that depends on where it sits does not move with it
Whether the qualifier precedes or follows the positive root is a property of the language, not of the string — so any assertion that depends on the root’s position is sound over one part of the corpus and unsound over the rest. Schematic, not measured data.

This is structural rather than unlucky, and the distinction is the reason the post exists. The qualifier has to sit on one side of the root or the other, and which side is a fact about the language’s word order. Any predicate built on position therefore cuts a translated corpus along a line that is completely invisible from the template locale. The same goes for containment: a negative prefix is an entirely ordinary way to build an antonym, in Latin and in Chinese and in Thai alike, so the overlap is a normal outcome rather than a coincidence.

Which is what separates this from the bug in the locale that breaks your layout, and the two are worth putting side by side. That one was a coverage gap: the assertion was right, it had simply never been run in German, and parameterising the loop over more locales fixed it outright. This one is a wrong premise. Running the same assertion over sixteen languages does not fix it — it makes it fail in more of them. Adding locales is the remedy for the first kind of mistake and no remedy at all for the second.

It is also not the argument we made in every zero has two explanations, and the difference is worth one clause. There the fault was in the instrument: a check read clean because the pipeline never ran, and a control would have caught it. Here a control would have passed. Enumerating from ground truth would have passed — the check genuinely did run over all sixteen locales. No amount of instrument hygiene reaches an assertion whose predicate is a grammatical fact about one language.

One boundary, so nobody reads more alarm into this than the record supports: the five prefix-shaped assertions that exist in our suite today are pinned to English by their own harness, so they pass and they are not failing. The unsoundness is latent. It would surface the day somebody parameterised that harness over locales, which is exactly the kind of improvement a person makes on a Tuesday without suspecting it changes what an assertion means.

The first fix was written in English too

Here is the part that makes the thesis more than a tidy framing. The rule we wrote to close the containment hazard — the durable, written-down one, the kind of artifact that outlives whoever wrote it — prescribed equality on the leading outcome token, or a prefix test paired with an inequality. It came with a stated rationale: the discriminator sits in first position.

That rationale is true of English. Measured against the corpus it is false in fourteen of sixteen languages, which is where the prefix numbers in the last section came from. The repair reproduced the defect it was written to prevent, one level up: an English morphological property generalised across sixteen grammars without anybody measuring it. And it was authored by the person who had the entire hazard laid out in front of him, in a document whose subject was that hazard.

This one never shipped. It was caught by measurement before it became the suite’s rule, and it was caught by the author of the rule. We are recording it as a near-miss rather than dressing it up, because the near-miss is the more useful artifact: it demonstrates that the failure is not a knowledge problem. You cannot reason your way out of the grammar you are reasoning in. Knowing about the trap, having just written a page about the trap, is not sufficient — the only thing that worked was running the relation over all sixteen files and reading the answer.

The answer was already shipping, one repo over

The hardest question in the whole set is whether you can special-case zero at all, given that a category rule you do not control decides where zero lands. It has an answer, it is unconditional, and it was one directory away for the entire investigation. Intl.pluralLogic takes a zero: argument and the same short-circuit that fires at one fires at zero, before any rule is consulted — so a zero: arm is used at zero in every language, whatever the category says. =0{…} in an ARB produces that arm. NeuralSpark ships two such keys across all sixteen of its locales today. The other project’s files use none, and its entire generated tree contains not one.

Nobody looked, and the reason is the same shape as everything else here. The question announced itself as a CLDR question, so it got researched where CLDR lives — and CLDR is exactly where that answer is not. Meanwhile a working implementation was sitting in a sibling app, in the same workspace, written by the same crew, doing precisely the thing everyone was theorising about. When a feature already works somewhere you can read, the first diagnostic step is a diff against it, not a theory. That is the unflattering lesson and it is the one with the highest resale value.

To be exact about what that does and does not imply: the Hindi behaviour did not need it. Singular-at-zero was ruled correct for that language, deliberately, and there is no open plural defect sitting in that app waiting on this. The point is not a fix somebody missed — it is where an answer turned out to live.

And the last fact is the one that binds all of it together. Not a single test went red on anything in this post. Key-count parity, JSON validity, static analysis and per-locale rendering were green in both projects, throughout, and all four are satisfied by a selector that has been silently rewritten and by a containment relation between two perfectly correct strings. Every finding here came from a person opening an artifact — sixteen ARB files, a generated Dart file, a commit body — and reading it. The gates were not asleep. They were answering the questions they were given, and the questions were written in English.

A localization defect is rarely a translation error; it is usually a correct decision made in the wrong grammar. Sixteen locales do not give you sixteen chances to catch one — they give one set of assumptions sixteen places to land. Ours were English. They were invisible from where we were standing, and every one of them was found by measuring the corpus instead of reasoning about it.

Notes

This is a first-hand account of two of our own apps as their records stood on 2026-08-03 — sixteen ARB files each, the Dart that flutter gen-l10n emits from them, and the commits that fixed what is described here. It carries no install, revenue or engagement figures, and deliberately no user-impact figure of any kind: nothing in these records measures how many players heard an ungrammatical announcement, or in which languages, or for how long. The strongest supportable statement about reach is the commit’s own — it fired on every one-star clear — and we did not trace when the French and Italian strings first acquired the wrong agreement, so no duration is claimed anywhere above.

The dispositions differ per finding and the difference is the point rather than a hole in it. The spoken star rating shipped, in three locales. The plural rewrite shipped and was ruled correct. Everything on the test-oracle side was prevented — found while writing an assertion, before an unsound one landed — including the mis-prescribed repair, which never became a rule. None of it was found by a failing test, and no test run was performed for this write-up; the dispositions come from commit bodies and from reading source, not from an observed suite run.

Documented versus measured, because it bounds the middle section. The plural categories are documented, from CLDR, and anyone can check them. The mapping from an ARB explicit-value selector to a generated category arm is ours: we did not find official documentation stating it, and established it by measuring the generator’s output across sixteen locales in two projects at the toolchain version we ship. Treat it as observed behaviour of that version rather than as a documented contract.

Two boundaries on the framing. The star-rating defect is an accessibility defect in the plain sense that the string is the only spoken route to that information; nothing here was audited against any standard and we make no conformance claim. And NeuralSpark appears in this post only as the source of a sixteen-locale string corpus and a test suite — nothing above is a claim about what any of our games does for the person playing it, and we make none: no cognitive, memory, focus, learning or performance outcome is claimed or implied, anywhere, however hedged. Iron Swarm, like the rest of our games, is free and ad-supported with one optional Pro purchase that removes the ads. We are not claiming an ad-free product, because that would be false.

Sources

  • Unicode CLDR — Language Plural Rules chart (CLDR 47). The reader-facing form of every category claim above: which languages have a one category at all, and which integers each one admits. It is the human-readable rendering, and it agreed with the machine-readable source on every locale we quote.
  • Unicode CLDR — common/supplemental/plurals.xml. The authoritative source for the exact rule conditions and integer samples quoted above, including Hindi and Vietnamese sharing one pluralRules element and pt_PT sitting in a different set from pt. Read on 2026-08-03 from the repository’s moving main branch, so the conditions are as they stood on that date rather than as of a tagged release.
  • Dart intl package. The explicit-number short-circuit that decides the singular arm at exactly one, and the zero arm at exactly zero, was read from the published source of version 0.20.2 in our own package cache. This bullet supports the mechanism; it does not support the ARB-to-generated-arm mapping, which is our measurement and not documented behaviour.

Keep reading: all posts on the WizusLabs Engineering blog.

← Back to the Blog