---
title: "The player's language"
description: "Every engine knows which languages its player reads, on every platform, and picks from one chain."
section: "Topics"
slug: "topics-localization"
canonical: "https://origozero.ai/docs/topics-localization"
updated: "2026-10-12T20:30:17.005358480+00:00"
---

# The player's language

## Showing your world in the player's language

Say which languages your content has text for, fallback first, and ask which
one this player reads:

```lua
local GREETING = { en = "Welcome!", pt = "Bem-vindo!", es = "¡Bienvenido!" }

local function refresh()
    label.text = GREETING[i18n.pick({ "en", "pt", "es" })]
end
refresh()
i18n.onChange(refresh)   -- the player changed their language while playing
```

`i18n.pick` runs the engine's chain (below) over your list: a Brazilian
player (`pt-BR`, then `en`) gets `pt`, a player on `es-419` gets `es`, and a
player whose languages your list does not hold gets the first entry. It
answers one of your tags exactly as you wrote it.

To see the world as a visitor with another language list would, stand that
list in, and put this machine's own back when done:

```lua
i18n.emulate({ "pt-BR", "en" })   -- i18n.preferred, pick, locale and onChange all follow
i18n.emulate(nil)                 -- this machine's own languages again
```

The player's own choice (`i18n.choose`, below) comes before the platform list
in the chain, so an emulated list decides only while no choice is set; clear
one with `i18n.choose(nil)`.

## The rest of the surface

```lua
i18n.preferred()    -- what the platform says the player reads, most preferred first
i18n.locale()       -- the locale the engine's own text is in: "en", "pt-BR", ...
i18n.offered()      -- the locales the engine offers for its own text, { tag, name, nativeName, fallback }
i18n.choice()       -- the player's own choice, or nil
i18n.sources()      -- every link of the chain and which one decided:
                    -- { locale, decidedBy, matched, choice?, zeromind?, platform, signedIn }
```

## What the platform says the player reads

`i18n.preferred()` is the platform's own list, as BCP 47 tags in its canonical
case (`pt-BR`, `zh-Hant-TW`), most preferred first:

| platform | read from | a change reaches the engine |
|----------|-----------|-----------------------------|
| Linux | `LANGUAGE`, `LC_ALL`, `LC_MESSAGES`, `LANG` (a POSIX name such as `pt_BR.UTF-8` reads as `pt-BR`; `C` and `POSIX` name no language) | at boot |
| Windows, macOS | the user's language list in the OS settings | at boot, and each time the engine's window regains focus |
| browser | `navigator.languages` | on the page's `languagechange` event |
| Android | the activity's configuration locales: the app's own language when the player set one for it, the device's language list otherwise | on the configuration change, while the app keeps running |

The list is what the platform reports and nothing more: a world that wants
the player's region or second language reads it here.

## How the locale is chosen

`i18n.locale()` and `i18n.pick` come from one chain. Each link is matched against the
offered locales, and the first link that matches decides:

1. **The player's choice**, set with `i18n.choose(tag)` and kept on the
   device for their ZeroMind account (the signed-out player has a choice of
   their own). It wins on every later boot.
2. **The language ZeroMind shows the player**: their account's language, or
   the page's. ZeroMind's page passes it to the browser engine as the
   `zeromindLocale` boot argument; a desktop launch passes
   `--zeromind-locale <tag>`. An engine booted without it skips the link.
3. **The platform's preferred languages**, in order.
4. **`en`**, the engine's source language. `i18n.pick` takes your list's
   first entry here instead, so the fallback your content names is the one
   it gets.

A link matches an offered locale with the same tag, then one its tag
truncates to (`pt-BR-x-abc` to `pt-BR`, `de-CH` to `de`), then the first
offered locale of the same language (`pt` to `pt-BR`).

The engine offers a locale once its manifest is launched. Each manifest
states the locale's tag, its name in English and in itself, its fallback
chain (the locales a string missing from it is looked up in, `en` last) and
whether it is launched. Until a locale launches, every player gets `en`, and
`i18n.sources().decidedBy` reads `default`.

```lua
local s = i18n.sources()
-- { locale = "en", decidedBy = "default", matched = "en",
--   choice = nil, zeromind = nil, platform = { "pt-BR", "en-US" }, signedIn = true }
```

## Choosing

```lua
local r = i18n.choose("pt-BR")
if not r.ok then
    log.warn(r.reason)   -- "`pt-BR` is not a locale the engine offers; it offers en"
end
i18n.choose(nil)         -- clear the choice; the chain decides again
```

Only an offered locale can be chosen. The choice is kept for the account the
engine is signed in as and comes back on the next boot on this device:

| platform | where |
|----------|-------|
| Linux, Windows, macOS | `<data root>/account_prefs/`, one JSON file per account (the data root the engine's other saved data lives under; `ZERO_DATA_DIR` overrides it) |
| Android | `account_prefs/` in the app's files directory |
| browser | IndexedDB database `zero-account-prefs` |

## Following changes

```lua
local id = i18n.onChange(function(locale, preferred)
    refreshLabels(locale)
end)
i18n.offChange(id)
```

A subscription made from a component ends with that component, and the
callback runs in edit mode and play alike. The callback runs once per change, after the engine renegotiated: a platform
language change while the engine runs (or an `i18n.emulate`), a choice made
or cleared, the ZeroMind language moving, or a locale launching. The
`locale` it receives is already the one `i18n.locale()` answers, and
`i18n.pick` inside it answers for the new state.

## Where else the locale goes

- Every request the engine sends ZeroMind on the player's behalf carries the
  chosen locale in the `x-zeromind-locale` header, the same header the web
  app's own requests carry.
- The text system picks its fallback fonts for the chosen locale (the
  regional glyph forms of a script follow it); `/zero/runtime/text/locale`
  reads the locale it uses.
