bevy-intl
A pragmatic internationalization (i18n) plugin for Bevy that loads translations from JSON files, supports fallback languages, named placeholders, CLDR-correct plurals, gendered text, and a reactive I18nText component for Bevy UI. WASM-friendly out of the box.
Features
- WASM compatible — translations are bundled at build time for web targets.
- Flexible loading — filesystem on desktop, bundled on WASM, or
bundle-onlyeverywhere via a feature flag. - JSON layout — one folder per language, one file per "namespace" (e.g.
ui.json,menu.json). - Named placeholders —
{{name}}substituted by name, with thei18n_args!macro for ergonomics. - CLDR-correct plurals — backed by
intl_pluralrules; Polish, Russian, Arabic etc. work as expected. - Gendered translations — single-axis or combined gender × plural via nested JSON.
- Reactive UI — drop an
I18nTextcomponent on an entity and it stays in sync as the language changes. - Fallback language — automatic fallback when a key is missing.
Quick start
[]
= "0.18"
= "0.3"
# Optional: force bundled translations on every target (e.g. for shipping a single binary)
# bevy-intl = { version = "0.3", features = ["bundle-only"] }
use *;
use ;
Version compatibility
| Bevy | bevy-intl |
|---|---|
| 0.18.x | 0.3.x |
| 0.17.x | 0.2.2 |
| 0.16.x | 0.2.1 |
MSRV — Rust 1.85 (uses std::sync::LazyLock and edition 2024).
Folder layout
messages/
├── en/
│ ├── ui.json
│ └── menu.json
├── fr/
│ ├── ui.json
│ └── menu.json
└── pl/
├── ui.json
└── menu.json
assets/
src/
A folder name that is not a recognized ISO/CLDR locale logs a warning at startup. Disable with I18nConfig.warn_unknown_locales = false if you intentionally use custom codes.
JSON format
Three shapes are supported per key:
{
"greeting": "Hello", // plain text
"farewell": { // single-axis: gender OR plural
"male": "Goodbye, sir",
"female": "Goodbye, ma'am"
},
"apples": {
"0": "No apples", // exact-count beats CLDR category
"one": "One apple",
"other": "{{count}} apples"
},
"guests": { // two-axis: gender × plural (nested)
"male": { "one": "{{count}} guest (M)", "other": "{{count}} guests (M)" },
"female": { "one": "{{count}} guest (F)", "other": "{{count}} guests (F)" }
}
}
Plural-key resolution priority
- Exact count —
"0","1","5", … - CLDR category for the active locale — resolved by
intl_pluralrules(so Polish getsone/few/many/other, Russian getsone/few/many/otherwith the right buckets, Arabic getszero/one/two/few/many/other, etc.). - Anglo-centric fallback —
"one"forcount == 1,"other"otherwise. - Last resort —
"many".
API
use *;
use ;
Deprecated —
t_with_argandt_with_gender_and_arg(positional placeholders) still work but ignore placeholder names in your JSON. Migrate tot_with_args/t_with_gender_and_argsfor proper named substitution.
Switching language
set_lang_i18n / set_fallback_lang are also available on App (via LanguageAppExt) for setting the language at startup before app.run():
use ;
new
.add_plugins
.set_lang_i18n
.set_fallback_lang
.run;
Reactive UI: I18nText
Spawn an I18nText next to any text node and it stays in sync — no manual rebuild loop, no boilerplate. When the language changes, every I18nText is re-rendered and a LanguageChanged message is broadcast.
use *;
use ;
Bevy 0.18 renamed buffered events to messages, so LanguageChanged derives Message and is read with MessageReader<LanguageChanged> (not EventReader).
WASM / platform behaviour
| Target | Default loading |
|---|---|
| Desktop | reads messages/ folder at runtime |
| WASM | uses bundled translations (compiled in by build.rs) |
To force bundled mode on every target — for example, to ship a single binary:
= { = "0.3", = ["bundle-only"] }
Migration 0.2 → 0.3
- Placeholders — replace
t_with_arg(key, &[&"John"])witht_with_args(key, i18n_args!{ name = "John" })(positional API kept but deprecated). - Plurals — exact-count keys still win. CLDR categories now resolve correctly for the active locale; if you authored translations against the old anglo-centric
3..=10 → "few"rule, double-check Polish/Russian/Arabic JSON. - Events —
LanguageChangedis aMessage(Bevy 0.18). UseMessageReader, notEventReader. - Type lifetime —
I18nPartialnow borrows fromI18n(I18nPartial<'_>). If you stored it in a struct, that struct now needs a lifetime parameter; usually you can just inlinei18n.translation("ui")at the call site. - Naming —
file_traductions/fallback_traductionare renamed (private fields, no API impact). - MSRV — bumped to Rust 1.85 (edition 2024).
License
Dual-licensed under either of:
- MIT License (LICENSE-MIT)
- Apache License 2.0 (LICENSE-APACHE)
at your option.