Localization

Loreline has built-in support for translating your scripts into multiple languages.

How it works

Localization in Loreline follows three steps:

  1. Tag translatable text with #key hash comments in your script.
  2. Create a translation file (.lang.lor) for each target language.
  3. Load the translation file at runtime and pass it to the interpreter.

The interpreter looks up each #key at runtime and replaces the original text with the translated version. If a key has no translation, the original text is used as a fallback.

Tagging text for translation

You don't have to add these keys by hand. The CLI can generate them automatically. See Using the CLI below.

To mark text for translation, add a #key hash comment after any translatable element. Three types of content can be tagged:

Text statements (narrative):

"The cafe smells wonderful." #intro

Dialogue (character speech):

barista: "Welcome! What can I get you?" #welcome

Choice options:

choice
  Espresso #opt-espresso
  Latte #opt-latte

Keys can be any combination of letters, digits, hyphens, and underscores, for example #intro, #opt-espresso, or #chapter2_greeting.

Literal hash characters

If you need a literal # in your text, use ## (doubled) or \# (escaped):

This has a literal ##hashtag in text.
barista: "Use \#escaped for literal hash."

Full example

Here's a complete script with localization keys:

character barista
  name: Alex

beat Menu
  "The cafe smells wonderful." #intro
  barista: "Welcome! What can I get you?" #welcome
  choice
    Espresso #opt-espresso
      barista: One espresso! #espresso-reply
    Latte #opt-latte
      barista: One latte! #latte-reply

Worried about readability? In the VS Code extension, press Cmd+Shift+H (Mac) or Ctrl+Shift+H (Windows/Linux), or use the command palette, to hide all localization keys so they never get in the way of reading or writing your script. The Playground on this website also has this option.

Translation files

Translation files use the naming convention Filename.lang.lor, for example CoffeeShop.fr.lor for French or CoffeeShop.de.lor for German.

Each entry in a translation file consists of:

Here's the French translation for the example above (CoffeeShop.fr.lor):

#intro // "The cafe smells wonderful."
Le café sent très bon.

#welcome // "Welcome! What can I get you?"
Bienvenue ! Qu'est-ce que je te sers ?

#opt-espresso // Espresso
Expresso

#espresso-reply // One espresso!
Un expresso !

#opt-latte // Latte
Latté

#latte-reply // One latte!
Un latté

The // comment on each key line is there for reference only. The interpreter ignores it. Translators can see the original text without needing to open the source file.

Alternate translation formats

By default Loreline only looks for .<locale>.lor files, but four other formats are supported as drop-in replacements: GNU gettext PO (.po), XLIFF 1.2 / 2.x (.xliff, .xlf), CSV (.csv), and TSV (.tsv). Useful when your team already uses an industry-standard translation tool, when you outsource translation to people who expect one of these formats, or when you want spreadsheet-friendly data.

Each format expresses the same six entries as the CoffeeShop.fr.lor example above. Pick whichever suits your workflow.

CoffeeShop.fr.po
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Language: fr\n"

msgctxt "intro"
msgid "The cafe smells wonderful."
msgstr "Le café sent très bon."

msgctxt "welcome"
msgid "Welcome! What can I get you?"
msgstr "Bienvenue ! Qu'est-ce que je te sers ?"

msgctxt "opt-espresso"
msgid "Espresso"
msgstr "Expresso"

msgctxt "espresso-reply"
msgid "One espresso!"
msgstr "Un expresso !"

msgctxt "opt-latte"
msgid "Latte"
msgstr "Latté"

msgctxt "latte-reply"
msgid "One latte!"
msgstr "Un latté"
CoffeeShop.fr.xliff
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en" target-language="fr" datatype="plaintext">
    <body>
      <trans-unit id="intro">
        <source>The cafe smells wonderful.</source>
        <target>Le café sent très bon.</target>
      </trans-unit>
      <trans-unit id="welcome">
        <source>Welcome! What can I get you?</source>
        <target>Bienvenue ! Qu'est-ce que je te sers ?</target>
      </trans-unit>
      <trans-unit id="opt-espresso">
        <source>Espresso</source>
        <target>Expresso</target>
      </trans-unit>
      <trans-unit id="espresso-reply">
        <source>One espresso!</source>
        <target>Un expresso !</target>
      </trans-unit>
      <trans-unit id="opt-latte">
        <source>Latte</source>
        <target>Latté</target>
      </trans-unit>
      <trans-unit id="latte-reply">
        <source>One latte!</source>
        <target>Un latté</target>
      </trans-unit>
    </body>
  </file>
</xliff>
CoffeeShop.fr.csv
key,source,fr
intro,The cafe smells wonderful.,Le café sent très bon.
welcome,Welcome! What can I get you?,Bienvenue ! Qu'est-ce que je te sers ?
opt-espresso,Espresso,Expresso
espresso-reply,One espresso!,Un expresso !
opt-latte,Latte,Latté
latte-reply,One latte!,Un latté
CoffeeShop.fr.tsv
key	source	fr
intro	The cafe smells wonderful.	Le café sent très bon.
welcome	Welcome! What can I get you?	Bienvenue ! Qu'est-ce que je te sers ?
opt-espresso	Espresso	Expresso
espresso-reply	One espresso!	Un expresso !
opt-latte	Latte	Latté
latte-reply	One latte!	Un latté
CoffeeShop.fr.lor
#intro // "The cafe smells wonderful."
Le café sent très bon.

#welcome // "Welcome! What can I get you?"
Bienvenue ! Qu'est-ce que je te sers ?

#opt-espresso // Espresso
Expresso

#espresso-reply // One espresso!
Un expresso !

#opt-latte // Latte
Latté

#latte-reply // One latte!
Un latté

Validity rule

Whatever you put in msgstr, <target>, or the locale column gets written back into a synthesised .lor body and re-parsed by Loreline. The string has to be valid Loreline body content, same rules as text in a .lor source file:

Translators who want to use these features just write the corresponding Loreline syntax in their localized string and it round-trips intact. Translators who want literal characters apply the same escapes they would in the original .lor.

Opting in

Alternate formats are opt-in at runtime, so projects that only use .lor translations don't pay any extra file-handler hits for formats they don't care about. Enable each family you want before calling loadLocale:

Loreline.translationFormat('po', true);
Loreline.translationFormat('xliff', true);
Loreline.translationFormat('csv', true);

The "po" switch covers .po. The "xliff" switch covers both .xliff and .xlf. The "csv" switch covers both .csv and .tsv. Unknown names are accepted silently for forward compatibility.

Multi-locale files

XLIFF and CSV/TSV can carry several locales in a single file. When the file has no .<locale> infix in its name (so CoffeeShop.xliff or CoffeeShop.csv rather than CoffeeShop.fr.xliff), Loreline still picks it up and selects only the entries that match the requested locale.

For XLIFF this means the target-language attribute on each <file> block decides which one applies. For CSV/TSV, the locale column header decides: a file with key,source,fr,de,es columns serves all three languages from one place.

Translating imports

Each .lor file can have its own translation file. If your story is split across multiple files using import, each imported file can have its own .lang.lor next to it.

For example, if your story looks like this:

// CoffeeShop.lor
import characters

beat Menu
  "The cafe smells wonderful." #intro
  barista: "Welcome!" #welcome
// characters.lor
character barista
  name: Alex
  greeting: "Hi there!" #barista-greet

You can translate each file independently:

// CoffeeShop.fr.lor
#intro // "The cafe smells wonderful."
Le café sent très bon.

#welcome // "Welcome!"
Bienvenue !
// characters.fr.lor
#barista-greet // "Hi there!"
Salut !

At runtime, Loreline walks all the files reachable from the root script and loads the corresponding translation file for each one. Files without a translation are skipped, and the original text is used as a fallback.

If two files happen to use the same #key (for example #intro in both CoffeeShop.lor and an imported prologue.lor), each file's translation file controls its own lines independently. You don't need to worry about key collisions between imported files.

Using the CLI

The loreline translate command automates the localization workflow. It can generate keys, create translation files, and clean up.

Auto-generate keys

If your script doesn't have #key tags yet, the CLI can add them automatically:

loreline translate CoffeeShop.lor --auto-ids

This scans all text, dialogue, and choice nodes in the script and inserts random keys (like #a7k2m) on any line that doesn't already have one. Your existing comments and formatting are preserved.

Generate a translation file

To create or update a translation file for a specific language:

loreline translate CoffeeShop.lor --lang fr

This creates CoffeeShop.fr.lor with all translatable entries. If the file already exists, existing translations are preserved and new entries are added.

To generate one of the alternate formats instead, pass --format:

loreline translate CoffeeShop.lor --lang fr --format po
loreline translate CoffeeShop.lor --lang fr --format xliff
loreline translate CoffeeShop.lor --lang fr --format csv
loreline translate CoffeeShop.lor --lang fr --format tsv

The flag accepts lor (default), po, xliff, csv, and tsv. The merge semantics are identical across formats: re-running on an existing translation file preserves user edits, adds new entries from the source, and updates the source reference when the original text has changed.

Remove keys

To strip all #key hash comments from a source file:

loreline translate CoffeeShop.lor --clear

Typical workflow

# 1. Add keys to your script
loreline translate CoffeeShop.lor --auto-ids

# 2. Generate a French translation file
#    (or use `--format po` / `--format xliff` / `--format csv` for an alternate format)
loreline translate CoffeeShop.lor --lang fr

# 3. Edit CoffeeShop.fr.lor, replace each placeholder with the French text

# 4. Add more languages as needed
loreline translate CoffeeShop.lor --lang de
loreline translate CoffeeShop.lor --lang es

When you add new text to your script, run --auto-ids again to tag the new lines, then --lang fr to update the translation file. Existing translations won't be overwritten.

Interpolation in translations

Translations work seamlessly with text interpolation. Variables like $count or ${expression} are preserved in the translation file and evaluated at runtime:

Source script:

state
  count: 3

beat Start
  You have $count items #item-count
  barista: You ordered $count drinks #drink-reply

Translation file:

Story.fr.lor
#item-count // You have $count items
Vous avez $count articles

#drink-reply // You ordered $count drinks
Vous avez commandé $count boissons
Story.fr.po
msgctxt "item-count"
msgid "You have $count items"
msgstr "Vous avez $count articles"

msgctxt "drink-reply"
msgid "You ordered $count drinks"
msgstr "Vous avez commandé $count boissons"
Story.fr.xliff
<?xml version="1.0" encoding="UTF-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en" target-language="fr" datatype="plaintext">
    <body>
      <trans-unit id="item-count">
        <source>You have $count items</source>
        <target>Vous avez $count articles</target>
      </trans-unit>
      <trans-unit id="drink-reply">
        <source>You ordered $count drinks</source>
        <target>Vous avez commandé $count boissons</target>
      </trans-unit>
    </body>
  </file>
</xliff>
Story.fr.csv
key,source,fr
item-count,You have $count items,Vous avez $count articles
drink-reply,You ordered $count drinks,Vous avez commandé $count boissons
Story.fr.tsv
key	source	fr
item-count	You have $count items	Vous avez $count articles
drink-reply	You ordered $count drinks	Vous avez commandé $count boissons

At runtime, $count is replaced with its current value (3), producing "Vous avez 3 articles".

Loading translations at runtime

To use translations in your application, call loadLocale() with the language you want. Loreline walks the script's imports and loads every available .lang.lor file, then gives you back a single translations map you can pass to the interpreter. Here's a JavaScript example:

import { Loreline } from 'loreline';

// Opt in to alternate translation file formats (skip this if you only use .fr.lor files):
Loreline.translationFormat('po', true);

// Parse the main script first, so loadLocale knows what imports to walk
const script = Loreline.parse(sourceContent, 'CoffeeShop.lor', handleFile);

// Load all French translations across the import tree
const translations = await new Promise(resolve =>
  Loreline.loadLocale('fr', script, 'CoffeeShop.lor', handleFile, resolve));

// Play with French translations
Loreline.play(script, onDialogue, onChoice, onFinish, null, {
  translations: translations
});

The same handleFile callback you pass to parse() is reused here to read each translation file. Missing translation files are ignored, so partial translations work fine.

Catching translation errors

Missing translation files are not an error: loadLocale silently skips them and the runtime falls back to the source text. A translation file that exists but is invalid (broken XML in a .xliff, an unterminated quoted string in a .po, a .csv missing its key column header, a .lor with a syntax error) is a different story and surfaces as a loreline.Error.

In sync mode (no callback), loadLocale throws and you catch it with try/catch. In async mode (callback provided), the call never throws: the callback fires with null and you read Loreline.lastError() to find out what failed:

Loreline.loadLocale('fr', script, 'CoffeeShop.lor', handleFile, (translations) => {
  if (translations == null) {
    console.error('Translation load failed:', Loreline.lastError().message);
    return;
  }
  // ... use translations
});

The same opt-in, --format, and error patterns apply in every supported language, using the idiomatic name (TranslationFormat and LastError in C#, translation_format and last_error in Python and Lua, and so on).

The same pattern applies in all supported languages. See the integration guides for details on C#, C++, Python, Lua, Haxe, and Godot.