Russian spintax: why your numbers always look wrong (and what we did about it)

If you ever wrote a Russian spintax template with a number in front of a noun — 3 языка, 15 слотов, 21 фриспин — there is a strong chance the rendered text is grammatically wrong on a quarter of the casinos in your catalogue. Until v1.5 of this engine, every spintax tool in the wild had the same gap, and there was no clean way to close it from a template.

The 30-second test

Three numbers. Three Russian forms of язык ("language"). Pick the right form for each:

CountCorrect form
1язык
2языка
11языков (not языка!)
21язык (back to singular!)
22языка
112языков

The rule depends on n % 10 and n % 100 together, with a special exception for 11–14. It is the same in Ukrainian and Belarusian. Polish has its own four-bucket version. Czech, Slovak, Slovenian have variants. Arabic has six buckets. None of these are expressible with the spintax primitives that existed before.

Three workarounds, all broken

1. Random pick by enum

%LangCount% {язык|языка|языков}

The synonym {a|b|c} picks one form at random — independent of %LangCount%. So 12 might render as "12 язык", or "12 языка", or "12 языков" — and only the last one is correct. The template syntax looks like it cares about the count. It does not.

This is the most insidious workaround because it passes a casual review. The text reads grammatical on the variant where the random roll happened to land on языков. Editors look at the preview, see one correct rendering, ship it, and only catch the bug months later when a Russian-speaking visitor complains.

2. Closed-set inline pairing

{50|100|150|200} фриспинов

The author hand-picks numbers that all happen to take the many form (5+). The noun is hardcoded as фриспинов. It works — for these four numbers — by accident. The moment the count comes from a real variable (%CasinoFreeSpinsAmount%), the correlation breaks and you are back to Workaround 1.

3. Nested has-flag conditionals

%CasinoLanguagesCount% {?CasinoHasOneLang?язык|{?CasinoHasFewLangs?языка|языков}}

The variable assembler computes three boolean flags per countable entity (CasinoHasOneLang, CasinoHasFewLangs, etc.) and fills them with "1" or "" based on the count. The template branches on the flags.

This can be made correct — the modulo math lives in the assembler, not the template. But:

  • Three flags per countable entity, per language. A casino with languages, cryptos, providers, bonuses, free-spins, days, hours = 7 entities × 3 flags × N languages.
  • Editor cannot author a new number + noun phrase without first asking an engineer to add the flag triple, deploy a new worker version, and only then write the template. Every counter is engineer-in-the-loop.
  • Literal numbers in copy ("за 30 дней", "5 крипт") cannot be addressed at all — there are no flags pre-computed for arbitrary literals.
  • The "1, 21, 31… except 11" exception still has to be expressed in the assembler. {?Flag?…} is a truthy/falsy gate, it cannot do n % 10.

Why no engine before us shipped this

The spintax family — GTW (Generating The Web), the original Russian SEO tool the syntax descends from; nested-spintax-for-acf, ReCsBlue, every WordPress plugin in the niche; the dozens of standalone "spintax generators" floating around — all share the same primitives: enumeration {a|b|c}, sometimes permutation [a|b|c], sometimes variables. None ship a plural primitive. The Russian web has been writing around the gap for a decade.

Meanwhile every modern i18n stack treats it as table-stakes:

  • ICU MessageFormat: {count, plural, one {1 язык} few {{count} языка} other {{count} языков}}
  • gettext: ngettext("язык", "языка", count) with a Plural-Forms header
  • FormatJS, Polyglot, i18next — all the same shape

These tools live in i18n, not content generation. Adopting one would mean migrating every existing {a|b|c} in your platform to the ICU syntax. Spintax stayed broken because the cost of switching tools was higher than the cost of vague copy.

What we shipped

A new spintax-native primitive that sits next to the existing ones, uses the same braces and pipes, and resolves number-to-form in the engine:

supports %CasinoLanguagesCount% {plural %CasinoLanguagesCount%: язык|языка|языков}

Three forms for Russian/Ukrainian/Belarusian (one|few|many), two for English-style locales (one|many), with the modulo math in the engine helper — not in the template, not in the assembler.

The count slot accepts a %Var% reference or a literal integer. Variable substitution runs first; by the time the plural pass executes, the count is already a literal. The literal "plural " prefix is the parser discriminator that keeps it from colliding with {a|b|c} synonyms even after expansion. The : separates count from forms unambiguously.

The catalogue effect

Three real casinos from a public catalogue:

CasinoCryptosProvidersLanguages
1xBet183816
888starz1128
BC.Game172412

Without the primitive, every casino renders the same vague phrase: "поддерживает множество криптовалют, среди которых Bitcoin, Ethereum, Tether". The factual difference between 18 and 1 cryptos is invisible. With the primitive, the same template renders three distinct sentences:

  • 1xBet: "поддерживает 18 криптовалют, среди которых Bitcoin, Ethereum, Tether"
  • 888starz: "поддерживает 1 криптовалюту, среди которых Bitcoin"
  • BC.Game: "поддерживает 17 криптовалют, среди которых Bitcoin, Ethereum, Tether"

SEO benefits from the genuine differentiation: every casino review carries facts unique to its catalogue. Readers benefit from concrete numbers instead of generic "many" / "various". Editors no longer reach for vague qualifiers because the tool finally lets them write what they mean.

Where it runs

  • WordPress plugin (this project, v1.5.0) — full PHP port. Wired into the render pipeline between conditional resolution and enumeration resolution. ~70 PHPUnit tests mirror the canonical TS implementation.
  • TypeScript engine — shipped first in casino-platform; vendored into spintax.net's bundle. Same algorithm, same edge cases.
  • Spintax.net playground — try it live in the browser, no install required.

The engine is permissively licensed and the primitive is documented as part of the spintax syntax surface, not a private extension. Any future spintax tool can adopt the same syntax without coordination — and we hope they do.

Try it

The playground ships with a plural example. Set %Count% to 0, 1, 2, 5, 11, 21, 22 in turn and switch the locale between en and ru — the bucket logic is visible end-to-end without a single line of editor work.

For the full implementation guide — every form, every edge case, the full pipeline placement, and the worked authoring patterns — read Plural agreement: {plural <count>: form|…}.