Text in several languages by message ID, with placeholders, plurals and a fallback language.
Import blitz.localisation As L gives your program these, with L. in front. Each name leads to its part of the reference.
LastError:Str()CreateLanguages:Languages(fallback:Str = "en")UseLanguage:Int(languages:Languages, language:Str)AddPluralRule:Int(languages:Languages, language:Str, rule:PluralRule)LanguageRevision:Long(languages:Languages)Translate:Str(languages:Languages, key:Str, arguments:Arguments)TranslateCount:Str(languages:Languages, key:Str, count:Long, arguments:Arguments)LoadLanguage:Int(languages:Languages, language:Str, file:Str)PluralRuleCategory:Str(count:Long)Localisation lets people play your game in their own language. Instead of
writing "Buy 3 apples" in your program, you give each piece of text an ID, such
as buy, and keep the words for each language in a file of their own. The
module looks up the words in the language the player chose, fills in names and
numbers, and picks the right form for the number: 1 apple but 2 apples, and in
French, 0 pomme but 2 pommes.

Open shop.bb and run it. Press L to switch between English and French, and Up and Down to change how many apples to buy. Watch the button's words change with the number, and its width with the language.
; A village shop in English or French. L switches language, and Up and Down
; change how many apples you buy. Escape quits.
Dialect "modern"
Import blitz.localisation As L
; Load both languages from their files, en.json and fr.json, beside this
; program. English is the fallback, used for anything French lacks.
Local languages:L.Languages = L.CreateLanguages("en")
If Not L.LoadLanguage(languages, "en", "en.json") Then RuntimeError L.LastError()
If Not L.LoadLanguage(languages, "fr", "fr.json") Then RuntimeError L.LastError()
Local args:L.Arguments = New L.Arguments ; The values that go into {name} and so on.
args.SetValue("name", "Alex")
Local language:Str = "en"
Local apples:Long = 1L
Graphics 640, 280, 0, 2
SetBuffer BackBuffer()
ClsColor 18, 52, 79
While Not KeyDown(1)
If KeyHit(38) ; L
If language = "en" Then language = "fr" Else language = "en"
L.UseLanguage languages, language
EndIf
If KeyHit(200) And apples < 12L Then apples = apples + 1L
If KeyHit(208) And apples > 0L Then apples = apples - 1L
; Each frame, look up every piece of text by its ID, in the language in use.
Cls
Color 255, 255, 255
Text 320, 30, L.Translate(languages, "title", Null), True
Text 320, 80, L.Translate(languages, "welcome", args), True
; TranslateCount picks the right words for the number, and fills in {count}.
Local buy:Str = L.TranslateCount(languages, "buy", apples, Null)
; Make the button as wide as its words, which are longer in French.
Local width:Int = StringWidth(buy) + 40
Color 40, 120, 180
Rect 320 - width / 2, 120, width, 44, True
Color 255, 255, 255
Text 320, 142, buy, True, True
Color 220, 60, 50
Local apple:Int
For apple = 1 To Int(apples)
Oval 296 - Int(apples) * 14 + apple * 28, 184, 20, 20, True
Next
Color 170, 200, 230
Text 320, 230, L.Translate(languages, "keys", Null), True
Flip
Wend
Delete args
Delete languages
End
The program loads two language files, which sit beside it. Then, every frame,
it looks up each piece of text by its ID, in the language in use. {name} is
filled in from the Arguments, and {count} with the number of apples. Try
0 apples in each language: English says "0 apples", but French says "0 pomme",
because French uses the singular for 0 and 1.
Each language has a file of its own. This is en.json, the English one that
the program above loads:
{
"version": 1,
"messages": {
"title": "The Village Shop",
"welcome": "Welcome, {name}! How many apples?",
"buy": {"one": "Buy {count} apple", "other": "Buy {count} apples"},
"keys": "Up/Down: how many L: en français"
}
}
version is always 1.messages holds each piece of text under its ID, the name your program
uses. IDs are case-sensitive.{name} marks where a value goes. For a brace itself, write {{ or }}.zero, one, two, few, many and other. Each language uses the ones
it needs, and English needs only one and other. Every such message must
have other, which covers any number the others don't. The forms must all
use the same {names}, but any of them can leave out {count}, as in
"Buy an apple".A file can also have a parent: the language to look in for anything the file
lacks, in place of the usual one.
UseLanguage takes the language's tag, such as "fr". It returns False, and
leaves the language as it was, if that language isn't loaded.
If Not L.UseLanguage(languages, "fr") Then Print L.LastError()
Put the values in an Arguments, by name, and pass it when you translate.
Values are text, so turn numbers into text with Str. For a message with
forms, use TranslateCount: it chooses the form, and fills in {count} for
you.
Local args:L.Arguments = New L.Arguments
args.SetValue("hero", "Renée")
args.SetValue("gold", Str(gold))
Print L.Translate(languages, "found", args) ; "{hero} found {gold} gold!"
Print L.TranslateCount(languages, "keys", 3L, args) ; "{hero} has {count} keys"
Delete args
Anything missing from the language in use comes from the fallback language,
the one you give CreateLanguages. A regional language, such as Canadian
French, first looks in plain French, so its file needs only the words that
differ.
If Not L.LoadLanguage(languages, "fr-CA", "fr-CA.json") Then Print L.LastError() L.UseLanguage languages, "fr-CA" ; Words not in fr-CA come from fr, then en.
Translating every frame is fine for a few words. For a lot of text, keep what
you translated, and translate again when LanguageRevision changes. It goes up
each time a language is loaded or chosen.
Local seen:Long = -1L
; Then, each frame:
If seen <> L.LanguageRevision(languages)
seen = L.LanguageRevision(languages)
title = L.Translate(languages, "title", Null)
EndIf
Load the file again. If the new file has a mistake, LoadLanguage returns
False, LastError says what's wrong, and the old words stay, so a translator
can fix the file and try again.
If Not L.LoadLanguage(languages, "fr", "fr.json") Then Print L.LastError()
Blip's tip
Never build a sentence by joining translated pieces, as in
L.Translate(languages, "buy", Null) + " " + count + " apples". Other languages put words in a different order. Make the whole sentence one message, with{count}or a{name}where the values go.
[id], and a
missing value as {name}, so you can spot them, and LastError says which.TranslateCount. Translate gives [id] for
them.AddPluralRule, as live_reload.bb does.Three more complete programs come with the module:
The reference below has every function and rule.
0.1.0 unpublished source preview. Resolve stable message IDs to UTF-8 text,
with named substitutions, non-negative integer plurals, explicit fallback and
atomic table reload. No package dependencies. Requires Extended
object-lifecycle-v1 (runtime 1114 / module ABI 5 or later).
Blitz3D+ comes with this module, so any Extended program can
Import blitz.localisation. Open an example and run it.
Dialect "modern"
Import blitz.localisation As L
Local languages:L.Languages = L.CreateLanguages("en")
If languages = Null Then RuntimeError L.LastError()
If Not L.LoadLanguage(languages, "en", "en.json") Then RuntimeError L.LastError()
Local args:L.Arguments = New L.Arguments
args.SetValue("name", "Alex")
Print L.Translate(languages, "welcome", args)
Print L.TranslateCount(languages, "gems", 2L, Null)
Delete args
Delete languages
End
en.json:
{"version":1,"messages":{"welcome":"Welcome, {name}!","gems":{"one":"{count} gem","other":"{count} gems"}}}
The complete examples/quick_start.bb generates its files, opens a window,
switches between English and French on click/Space, and removes its files.
inventory_menu.bb switches English/German/French, collects/uses gems and changes
character names. It measures labels again and wraps buttons after every change.
live_reload.bb saves valid/broken candidate files, applies them with Enter,
keeps visible content on failure, switches language, and shares one custom rule
between two independent stores. All examples accept finite --verify runs.
Files use UUID names, remain local, and are deleted on ordinary exit. An aborted
example may leave only its uniquely named localisation-* files/directory.
| Operation | Contract and example |
|---|---|
CreateLanguages:Languages(fallback:Str="en") | Caller owns result. Invalid tag returns Null + LastError. No file read. Initial selection is fallback and initial revision is zero. Local s:L.Languages=L.CreateLanguages("fr") |
LoadLanguage:Int(store, tag:Str, file:Str) | Borrow store; read/validate candidate; success replaces only that tag and advances revision. False preserves selection, tables and revision. If Not L.LoadLanguage(s,"fr",file) Then Print L.LastError() |
UseLanguage:Int(store, tag:Str) | True selects an exact/parent-supported tag and advances revision, including repeated selection. Unknown/malformed tags return False + LastError. L.UseLanguage s,"fr-CA" |
Translate:Str(store, key:Str, arguments:Arguments) | Explicit Null for no arguments. Copies returned text. Missing key gives [key]; missing argument gives {name}; each sets LastError. label=L.Translate(s,"welcome",args) |
TranslateCount:Str(store, key:Str, count:Long, arguments:Arguments) | Non-negative cardinal count; automatic decimal {count} even in a plain template. Caller count argument or negative count is a programming error. label=L.TranslateCount(s,"gems",2L,Null) |
AddPluralRule:Int(store, tag:Str, rule:PluralRule) | Borrow rule until replacement/deletion. Invalid tag => False + LastError; Null/deleted rule => programming error. Advances revision on success. L.AddPluralRule s,"xx",rule |
LanguageRevision:Long(store) | Read current revision, including inside a rule. Success of load/use/rule registration advances once; failure never does. If seen<>L.LanguageRevision(s) Then ... |
LastError:Str() | First diagnostic from the latest fallible operation, at most 512 UTF-8 bytes; success clears it. Copy before another operation. Local problem:Str=L.LastError() |
Arguments | New L.Arguments; args.SetValue("hero","Zoë"); args.Clear() removes all arguments; Delete args releases its Map. SetValue copies text. Names: [A-Za-z_][A-Za-z0-9_]{0,63}. Invalid name/UTF-8 => programming error. |
PluralRule | Implement Method Category:Str(count:Long) returning zero/one/two/few/many/other. Pure main-thread callback; see ownership below. |
SetValue is the single supported spelling; there is no Arguments.Set.
Object/interface optional Null
defaults are not accepted by this compiler, so translation calls pass explicit
Null. AddPluralRule's Int result and LastError make recoverable tag errors
explicit.
Null/deleted Languages and invalid callback actions fail with RuntimeError.
A deleted Arguments alias compares Null and is treated as no arguments. Empty
translations/argument values are present, valid values. Translate on a plural
message requires TranslateCount; otherwise it returns [key] with a diagnostic.
The diagnostic is one bounded string, not an accumulating log or event queue.
It omits the runtime's possibly byte-truncated JSON path and retains line/column.
Root members are exactly version (integer 1), messages (object), optionally
parent (valid nonempty tag). Unknown members, duplicate JSON keys, extra roots,
comments, trailing commas, malformed UTF-8/surrogates/escapes, unsupported versions
and wrong kinds are rejected. One leading UTF-8 BOM is accepted. JSON whitespace
includes LF, CRLF and CR; escaped line endings and Unicode scalars are preserved.
Escaped NUL is valid JSON and retained in returned strings; drawing controls/NUL
has no promised visual meaning. UTF-16 files are not accepted.
Each message is a string or a category-to-string object with required other.
Categories are exactly zero, one, two, few, many, other. Unknown
categories and nested plural objects are errors. All branches must contain the
same named placeholders, except count may be omitted in a spelled-out singular.
Cross-language wording can differ; this is not a translation-authoring service.
Message IDs are case-sensitive, nonempty UTF-8, at most 256 bytes.
{name} substitutes one literal argument. {{ and }} emit literal braces;
{{{name}}} wraps the value in braces. Inserted {count} text stays literal:
there is no recursive expansion. Missing values stay visibly {name}. There is
no implicit number/date formatting. Only TranslateCount supplies count.
Templates are compiled once on load; calls allocate returned strings, not new
parse trees or caches indexed by argument combinations.
Limits: 64 installed tables per store, 10,000 messages per table, 65,536 UTF-8 bytes and 256 compiled segments per template. Runtime JSON limits also apply (64 MiB source, depth 128 and bounded retained JSON values/text). Load temporarily holds the old table and the candidate. Expected validation failures free the whole candidate and preserve the old table. Fatal allocation/runtime errors end the program; they are not recoverable reload failures.
Supported syntax: 2–3 ASCII language letters, optional 4-letter script, optional
2-letter or 3-digit region, separated by - or _. Case folds to lowercase;
subtag values remain present (ZH_Hant_TW => zh-hant-tw). No whitespace,
variants, extensions, aliases or implicit locale database. Syntax acceptance
does not assert that a locale exists. This API never consults system language.
Lookup starts with the selected exact tag, follows configured parent overrides
or progressively strips the rightmost subtag, then searches the explicit fallback
and its parents. An override replaces the structural parent, allowing a project
to define its own fallback chain. Cycles, including cycles through unloaded tags,
are rejected on load. Shared tails may be visited again in the fallback pass but
cannot loop. Missing keys return [key] even if no tables are loaded.
Selection needs an installed exact/parent table; the global fallback alone does not make an unknown selection succeed. The requested tag is remembered, so later loading an exact table takes effect without another UseLanguage call.
Plural rules belong to the resolved message table, not the selected UI tag.
A category missing from that message uses its required other. Built-in rules
are pinned to CLDR 47 cardinal integer rules, ordinary integer operands:
| Languages | Integer categories |
|---|---|
| en, de | one for 1; other otherwise |
| es | one for 1; many for positive multiples of 1,000,000; other otherwise |
| fr | one for 0/1; many for positive multiples of 1,000,000; other otherwise |
| ja, zh | other for every integer |
The supported script/region descendants of these six bases inherit that base's
integer rule. CLDR 47 defines no separate regional predicates for these selected
six families. This does not generalise to other languages. An exact registered
custom rule overrides inheritance; structural parent registrations precede the
base. Parent translation overrides do not change rule inheritance. Register a
rule before loading plural messages for any other base (the advanced example
uses xx for an explicitly fictional game grammar). No rule is guessed.
Counts cover 0..9223372036854775807 exactly, including values above 2^53. No Float
conversion is used. Fractional/negative/ordinal/compact-exponent operands are
outside v1. Data provenance and the Unicode notice are in provenance.md.
| Value | Owner and lifetime |
|---|---|
| Languages | Caller; Delete releases every table/message/template/segment, collection and built-in rule. No callbacks, file I/O, save or implicit commit on destruction. |
| Arguments | Caller; borrowed only during Translate/TranslateCount. Delete releases its Map. |
| Custom PluralRule | Caller; all stores borrow it. Replace/remove borrowing stores before deleting rule. Replacing a rule does not delete the previous one. |
| Returned Str | Independent copied value; survives reload and deletion of store/arguments. |
| JSON handles/files | Load owns/frees all temporary handles. It borrows the filename and never edits/deletes the file. Examples own only their generated files. |
Category must be pure. Mutation/re-entry of the active store, deletion of that store, or SetValue/Clear/Delete on active Arguments is a programming error. Read-only LanguageRevision is allowed. Return a supported category; any other result is a programming error. Keep the rule alive throughout the call. Deleted borrowed rules are detected before invocation. Callback RuntimeError/End ends the program; the module does not promise recovery from an aborted callback. No clock, random source, input polling or rendering occurs inside the resolver. Main-thread only; output depends on tables, arguments, count and the supplied pure rule.
The runtime stores Str as bytes; template boundaries are ASCII delimiters and UTF-8 is never decoded/re-encoded during substitution. Diagnostics shorten only at scalar boundaries. This module does not add glyphs, reverse strings, change fonts, perform bidi/shaping or measure UI. Retained UI observes revision, calls Translate again and uses the runtime's StringWidth/Text functions.
The browser system-font path uses Canvas UTF-8 text measurement/rasterisation. The examples deliberately demonstrate Latin English/German/French and accented names. Japanese/Chinese are tested as resolver data and plural rules, not advertised as verified sample glyph coverage. There are no bundled binary fonts or images. Store only language-independent IDs and game values in saves; rebuild labels from them after load. No save-game or UI adapter is included.
Deferred: fractional/ordinal plurals, number/date/currency formatting, automatic locale discovery, locale databases/services, shaping/bidi/font management, workers and adapters. Save/commit/cancel policies belong to the application. Reload is the only table commit; deleting a store never saves or commits a pending candidate.