Localisation

Text in several languages by message ID, with placeholders, plurals and a fallback language.

Commands

Import blitz.localisation As L gives your program these, with L. in front. Each name leads to its part of the reference.

Functions

Types

Interfaces

Guide

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.

A village shop menu in French, with a button to buy three apples

Try it

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.

Write a language file

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"
  }
}

A file can also have a parent: the language to look in for anything the file lacks, in place of the usual one.

How do I...

Switch language?

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()

Fill in names and numbers?

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

Fill the gaps in a translation?

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.

Translate only when the language changes?

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

Change the words while the game runs?

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.

Good to know

More examples

Three more complete programs come with the module:

The reference below has every function and rule.

Reference

Needs
No other modules
Examples
inventory_menu.bb, live_reload.bb, quick_start.bb

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.

Public API and failures

OperationContract 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()
ArgumentsNew 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.
PluralRuleImplement 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.

Schema and templates

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.

Tags, fallback and plural coverage

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:

LanguagesInteger categories
en, deone for 1; other otherwise
esone for 1; many for positive multiples of 1,000,000; other otherwise
frone for 0/1; many for positive multiples of 1,000,000; other otherwise
ja, zhother 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.

Ownership, callbacks and rendering

ValueOwner and lifetime
LanguagesCaller; Delete releases every table/message/template/segment, collection and built-in rule. No callbacks, file I/O, save or implicit commit on destruction.
ArgumentsCaller; borrowed only during Translate/TranslateCount. Delete releases its Map.
Custom PluralRuleCaller; all stores borrow it. Replace/remove borrowing stores before deleting rule. Replacing a rule does not delete the previous one.
Returned StrIndependent copied value; survives reload and deletion of store/arguments.
JSON handles/filesLoad 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 work

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.