Translation and Localization
Every piece of user-visible text in a SceneryStack simulation is a StringProperty (or a TReadOnlyProperty<string> derived from one), never a plain string baked into a Text node. That single decision is what makes translation possible: swapping the value underneath an existing Property re-renders anywhere that string is displayed, with no manual re-rendering step.
Strings live in Properties, not literals
import { Text } from 'scenerystack/scenery';
import { StringProperty } from 'scenerystack/axon';
const greetingProperty = new StringProperty( 'Hello!' );
const greetingText = new Text( greetingProperty, { font: '20px sans-serif' } );
// Changing the underlying value updates every Node displaying it automatically.
greetingProperty.value = 'Bonjour !';Text and RichText both accept a TReadOnlyProperty<string> directly wherever they'd otherwise take a plain string, so passing a StringProperty (or a DerivedProperty/PatternStringProperty built from one) is idiomatic, not a special case.
The PhET translation pipeline (conceptual)
In a full PhET-style project, you don't hand-construct every StringProperty yourself. The workflow is:
- Author English strings once, in a JSON file at the project root (conventionally
<sim-name>-strings_en.json) — see Project Structure Conventions. - A build-time step generates a strongly-typed strings module from that file, exposing one
StringPropertyper key. - Translators submit additional per-locale JSON files (
<sim-name>-strings_fr.json, etc.) through PhET's translation tooling, without touching any TypeScript. - At runtime, the active locale determines which JSON file's values populate the generated
StringPropertyinstances — the application code that consumes them (Text,RichText, anyTReadOnlyProperty<string>consumer) never changes.
The exact generation step is part of the PhET build tooling and out of scope for simulation-author-facing code in Almanach, but the contract you write against is stable regardless: every generated string is a Property<string>, with the same .value/.link() API described in StringProperty and Property.
Interpolating values into translated strings
Never build a translated string with template literals or concatenation — word order and pluralization vary across languages, so a fixed English sentence shape breaks translation. Use StringUtils.fillIn (or the axon PatternStringProperty, which wraps the same mechanism as a reactive Property) with named placeholders instead:
import { StringUtils } from 'scenerystack/phetcommon';
// pattern (usually itself a translated StringProperty's value): '{{name}} scored {{points}} points'
const message = StringUtils.fillIn( messagePatternProperty.value, {
name: 'Ada',
points: 42
} );import { PatternStringProperty } from 'scenerystack/axon';
// Reactive version: recomputes whenever the pattern or values change.
const scoreMessageProperty = new PatternStringProperty( messagePatternProperty, {
name: playerNameProperty,
points: pointsProperty
} );Runtime locale
localeProperty (from scenerystack/joist) reflects the simulation's active locale and is normally set once at startup from the ?locale= query parameter, not changed mid-session by application code:
import { localeProperty } from 'scenerystack/joist';
console.log( 'current locale:', localeProperty.value ); // e.g. 'en', 'fr', 'es'Don't hand-roll a second translation mechanism
It's tempting to reach for Intl or a homegrown lookup table for "just this one string." Keeping every displayed string on the StringProperty pipeline — even ones you're sure will never need translation — means the whole simulation's localization surface stays discoverable and translatable in one pass, rather than some strings quietly falling outside the pipeline.
Where to go next
StringProperty— the Property type every translated string is built on- Preferences and Feature Flags — where locale and other runtime settings are surfaced to users
- Project Structure Conventions — where the source strings JSON file lives in a repo