Skip to content

Translation

Every player-facing string (names, labels, short labels, descriptions, group labels, choice prompts) is looked up by key. None is written inline next to the mechanics. Adding a language therefore never means restructuring a pack.

  • One file per language per pack: i18n/<lang>.yaml, a flat map from key to message (i18n.schema.json). Language tags are BCP 47 (en, de, pt-BR).
  • A large pack may split a language across i18n/<lang>/*.yaml; the build merges them into one file per language. A key defined twice is an error.
  • The file for the pack’s defaultLanguage MUST exist and MUST contain every required key (below). Other languages may be partial.
i18n/de.yaml
weapons.longsword.name: Langschwert
spells.fireball.name: Feuerball

Messages use ICU MessageFormat for placeholders and plurals, for example "{base} +1" and "{count, plural, one {# charge} other {# charges}}".

Markup: a description, any key ending in .description, and a block (.block) are CommonMark with GFM tables, after ICU formatting. Apps render it, or show it as plain text, which Markdown reads as. HTML isn’t allowed in a description or a block: the build rejects a raw HTML block or tag (an autolink, <https://…>, isn’t HTML), so apps never render a pack’s HTML. Every other message is plain text.

spells.fireball.description: |-
A bright streak flashes from your pointing finger to a point you choose …
***At Higher Levels.*** When you cast this spell using a spell slot of 4th level or higher, …
Key For Required in the default language
pack.name The pack’s display name yes
headline, headline.separator The character’s headline, plain text, and the separator of its lists (Headlines) no
<contentId>.name Each content item (weapons.longsword.name) yes
<contentId>.description Optional longer text no
<contentId>.texts.<name> A named text the item’s content type declares (texts): a spell’s material, trigger no
<contentId>.state.<key>.name A state key an item or template declares no
<contentId>.state.<key>.values.<v>.name An enum state key’s value, for a key an item or template declares; a key its type declares too falls back to the type’s yes, unless the type declares the key
<contentId>.computed.<name>.name A computed value an item or template declares no
<contentId>.block An item’s own block, replacing its type’s; a template’s only block (Blocks) no
<contentId>.headline An item’s own headline piece, replacing its type’s (Headlines) no
fields.<fieldId>.name Each field (fields.abilities.str.score.name); {name} is a named field’s name (nameFrom) yes, unless the field is hidden
fields.<fieldId>.short Short label (STR) no
fields.<fieldId>.values.<v>.name An enum field’s value (fields.creature.size.values.medium.name) yes, unless the field is hidden
types.<type>.name Each content type yes
types.<type>.state.<key>.name A content type’s state key (types.cohort.state.harm.name) no
types.<type>.computed.<name>.name A content type’s computed value (types.cohort.computed.quality.name) no
types.<type>.block A content type’s block: its items’ card header (Blocks) no
types.<type>.headline A content type’s headline piece: one of its items in the headline’s {type_<type>_each} (Headlines) no
types.<type>.fields.<field>.name, .description An item field’s name and one-line hint (types.weapon.fields.damageType.name), for a custom-item form (Item fields) yes (name), every item field, a hidden one too
types.<type>.fields.<field>.values.<v>.name An item field’s value: an enum’s (types.spell.fields.school.values.evocation.name), or any other no
types.<type>.state.<key>.values.<v>.name A state key’s value, likewise; an enum key’s values are required yes for an enum key’s values
types.<type>.copies A content type’s copy limit, for too-many-copies (max, count as arguments) no
templateFields.<name>.name, .description A template field’s name and hint, in each pack that declares it yes (name)
tags.<tag>.name, .description A tag, in the pack that declares it (tags.finesse.name) yes (name)
groups.<groupId>.label Display groups (default label key) yes
groups.<groupId>.sections.<key>.name A content group’s section: groups.spells.sections.0.name no
units.<unitId>.name, .short A display unit: “feet”, “ft” yes, when a hint names the unit
actions.<actionId>.name Rules-level actions yes
<contentId>.actions.<actionId>.name Actions defined inline on a content item yes
<actionKey>.options.<optionId>.name Written options of a choice step (actions.weapon-attack.options.hit.name) yes
<actionKey>.steps.<stepId>.prompt A choice step’s or an allocate step’s question yes
<contentId>.options.<optionId>.name An option a content item adds to choice steps (option effects) yes
types.<type>.options.<optionId>.name An option a content type adds for each of its items, when the item doesn’t name it yes
<actionKey>.steps.<stepId>.name A roll step’s name no; yes for the rolls of the action a creation start names
<actionKey>.params.<name>.name An action parameter’s label yes
<actionKey>.params.<name>.description A parameter’s one-line hint, for its input (“The target to beat”) no
<actionKey>.params.<name>.values.<v>.name An enum parameter’s value (actions.cast-spell.params.slotType.values.pact.name) yes
<actionKey>.participants.<role>.name An action role no
<actionKey>.results.<result>.name A declared result (not a band set: those use bands.<set>.<band>.name) yes
<actionKey>.description Optional longer text for an action no
fields.<fieldId>.description Optional longer text for a field (a tooltip) no
situational.<id>.name Situational choices yes
situational.<id>.values.<v>.name An enum situational choice’s value (situational.bonusDie.values.devils-bargain.name) yes
bands.<set>.<band>.name Result bands yes
bands.<set>.<band>.condition How a band’s tests read in a roll summary; ICU value, then value1, value2, … per test no
tallies.<name>.name Roll tallies no
links.<slot>.name Link slots no
followUps.<id>.name, .description Rules follow-ups, derived and ephemeral offers alike; on content <contentId>.followUps.<id>.…, on a type types.<type>.followUps.<id>.… no
<owner>.choices.<choice>.prompt Choice prompts; <owner> is the content ID, or rules for rules-level choices yes
<owner>.choices.<choice>.options.<id>.name Inline options; a value choice’s values, <id> the value (optional) yes
<owner>.prerequisites.<id> Prerequisite text (“needs Str 13”) yes
creation.<id>.name, .description A creation start; both required, the description one sentence yes
constraints.<id> Constraint text (“at most 3 attuned items”) yes

<actionKey> is an action’s key root: actions.<actionId> for a rules action and <contentId>.actions.<actionId> for an action defined inline on an item. A paused action and each log entry name their defining action (action), so an app builds these keys for nested actions too (engine-actions.md); <stepId> is the last segment of the step’s path.

A choice step over content or copies has no option keys of its own: each option is named by its item’s name, <contentId>.name (a copy by its item’s, or by its own name from its state, computeSheet’s copyNames). The reserved option #done, which ends a repeated choice step, is named by actions.options.done.name, standard text the spec ships in ../i18n/en.yaml for every pack.

A choice step over pending choices has no text of its own: each pick is named by the choice it asks, <owner>.choices.<choice>.prompt with needs.choice.owner, and its options as listOptions results are. Its #done ends the whole step, leaving every choice still pending, so it is named by actions.options.done.pending.name (“Skip the rest”).

A slotted choice has one prompt for all its slots, under its declared ID, with the slot’s number as the ICU argument {slot}: classes.fighter.choices.class-feat.prompt: 'Class feat ({slot, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} level)'. A pending slot and needs.choice carry slot: { choice, number } for the app to find the key and fill the argument; options and nested choices are keyed under the declared ID too. The build warns about a slotted choice’s prompt that doesn’t use {slot}.

An option content adds to a choice step (an option effect) has the ID <qualified content id>#<option id>; it is named in the content’s pack by <contentId>.options.<optionId>.name (special-abilities.battleborn.options.special-armor.name), whatever action it is offered in, so each item can say which ability allows it and when. A content type’s option has its item’s ID too, and is named by the item’s key when the item has it, else by types.<type>.options.<optionId>.name in the type’s pack.

An explicit label, shortLabel or prompt in the mechanics overrides the default key (a roll step’s label, a choice step’s prompt and a parameter’s label too). An explicit key is always required. Keys are permanent, like IDs.

An outcome’s data keys have no names: they’re mostly numbers and flags (attack: 22, critical: true) that an app formats itself.

Named texts: a content type may declare texts: [<name>, …]. Each item of the type may have <contentId>.texts.<name>; none is required, and apps know from the type which to look up and where to show them (a spell’s material component and a reaction’s trigger, in its header):

# rules: contentTypes.spell.texts: [material, trigger]
spells.raise-dead.texts.material: a diamond worth at least 500 gp, which the spell consumes
spells.shield.texts.trigger: which you take when you are hit by an attack or targeted by the magic missile spell

State keys and computed values are named by types.<type>.state.<key>.name and types.<type>.computed.<name>.name when the content type declares them, and <contentId>.state.<key>.name and <contentId>.computed.<name>.name when an item or template does. A name an item’s declaration replaces falls back to the type’s key. An app may leave out a state key or computed value without a name. An enum state key’s values are named, types.<type>.state.<key>.values.<v>.name for a type’s key and <contentId>.state.<key>.values.<v>.name for an item’s own (Fate’s custom stunt’s limit), both required in the default language; an item’s value falls back to its type’s when the type declares the key too.

Tags have a name and an optional description, tags.<tag>.name and tags.<tag>.description (tags.finesse.name), in the pack that declares the tag, as a field’s text is in the pack that declares the field. The name is required for every declared tag, a declared tag’s parents included (declaring save.int declares save), so an app can name a tag stored as a value (a set field’s member). A pack that only uses another pack’s tag doesn’t name it.

Bands: a roll’s log entry and its step result carry bandSet, the qualified ID of the band set it was classified by, next to band, so an app builds bands.<set>.<band>.name (with the set’s unqualified ID, in the set’s pack) without reading the action (engine-actions.md). A roll’s summary lists the bands it can still reach, each with structured tests; an app words them with the band’s optional bands.<set>.<band>.condition text (value is the first test’s number, value1, value2, … each test’s in order), else the spec’s compare.<code> text per test (engine-actions.md).

Unused keys in the default language are warnings, not errors. A named text a content type doesn’t declare, text for a tag the pack doesn’t declare, and a value name for an undeclared enum value are unused keys.

Template names receive the base item’s translated name as the ICU argument {base}: magic-items.weapon-plus-one.name: '{base} +1'. (ICU argument names cannot contain dots, so it is {base}, not {base.name}.)

An item field is the item’s own data, written by whoever writes the item (a pack’s author, or a player making a custom item): the engine never changes it, and whatever changes on a copy is a state key. So every item field is author-facing, and each is named, types.<type>.fields.<field>.name, with an optional one-line hint, .description. An app building an “add a custom item” form from a content type’s fields labels each input with them (text.typeField, text.typeFieldDescription), and saves the item with setHomebrew and addItem. The display hint hidden on an item field keeps it off the item’s card, not out of the form: a form asks for every field, and may fold hidden and optional ones under “more”. A custom template’s form names the rules’ template fields by templateFields.<name>.name. An action’s inputs get hints the same way: a parameter’s optional <actionKey>.params.<name>.description (text.paramDescription).

types.weapon.fields.versatileDamage.name: 'Two-handed damage'
types.weapon.fields.versatileDamage.description: 'Damage with the versatile property, used in two hands.'
templateFields.acBonus.name: 'AC bonus'
actions.test.params.dr.description: 'The target to beat. 12 for most rolls; the GM raises or lowers it.'

A block words a content item’s card header from its mechanics, for an app to show above the description: types.<type>.block for every item of a type, <contentId>.block for one item (it replaces the type’s). It is an ICU message, then Markdown, a line or a few; the description stays the item’s text.

types.spell.block: >-
**{name}** — *{level, select, 0 {{school_name} cantrip} other {Level {level} {school_name}}}*
· {castingTime_name} · {rangeKind, select, ranged {{range} ft} other {{rangeKind_name}}}
· {verbal, select, true {V} other {}}{somatic, select, true {{verbal, select, true {, } other {}}S} other {}}
· {durationUnit_name}
types.spell.fields.school.values.evocation.name: 'Evocation'
types.spell.fields.castingTime.values.action.name: '1 action'
# Fire Bolt: **Fire Bolt** — *Evocation cantrip* · 1 action · 120 ft · V, S · Instantaneous

A template’s arguments:

Argument Value
{name} The item’s name; a copy’s, with its templates
{<field>} An item field the type declares: a copy’s own field, else the item’s, else the type’s default. Numbers and booleans as they are; a set is a list
{<field>_name} The value’s name: its types.<type>.fields.<field>.values.<value>.name (an enum’s, or any value’s: orc-2e’s light Bulk 0.1 is L), else a content ID’s item name, a tag’s name, else the value; a set’s as a list
{tags}, {tags_name} The item’s tags, and their names, as lists
{texts_<text>} A named text the type declares ({texts_material})
{self_<key>} A copy’s state key or computed value, when the app gives a copy; {self_<key>_name} its value’s name (the item’s own <contentId>.state.<key>.values.<value>.name, else the type’s)
{tag_<tag>} true when the item has the tag, or one below it, else false. The tag is spelt with each . and - as _, since ICU argument names can’t hold them: {tag_trait_cantrip}
{has_<argument>} true when any argument above has a value (not absent, not empty), else false
  • Precedence: name, tags, tags_name and texts_* first, then the type’s declared fields, then the has_, tag_, self_ and _name forms.
  • A tag test picks a wording by trait: orc-2e’s spell header reads “Cantrip 1” for a cantrip, {tag_trait_cantrip, select, true {Cantrip} other {Spell}} {rank}. Two declared tags that spell the same (a.b and a-b) can’t be told apart; a pack avoids declaring both.
  • Templates have no content type, so a template’s block is its own <contentId>.block. Its fields are formulas over the item it’s applied to, so only the ones written as a number or a boolean are arguments ({potency}); {name} is its name on its appliesTo type’s (Flaming {base} on a weapon is “Flaming Weapon”); tags, tags_name, tag_ and has_ work as for items. orc-2e’s runes: runes.flaming.block: '**Flaming rune** — *Rune 8* · {tags_name} · 500 gp'.
  • Missing values are the empty string: they print nothing and a select takes other. A plural of nothing isn’t zero, so a part that needs a value tests it first: {has_maxCharges, select, true { · {maxCharges} charges} other {}}.
  • Lists (a set, tags) are joined with the language’s list format.
  • What ICU can’t do is the pack’s: a dice field prints in dice notation ({damage}, 8d6), a unit is a word in the template, letters are a select. A display value the template can’t build is a field or computed value the pack adds, not template syntax.
  • Value names are text in their own right, so apps can use them outside a block (a filter or a column); write them as they read best in the pack’s blocks. A template that needs another wording writes a select.

An app shows an item’s block, then its description, separated by a blank line; either alone when the other is missing. @opencharacter/text gives that as contentCard and the block alone as contentBlock (a block that doesn’t format is no block).

pnpm validate checks a block: it parses as ICU, has no HTML, and names only arguments its type gives (its declared fields, named texts, state keys and computed values; for <contentId>.block also the item’s own; a declared tag for tag_; for a template its literal fields). A _name of an enum of the pack’s own type needs a name for each value in the default language.

A headline is a one-line description of a character, for an app to show under its name in a list: “Level 3 Hill dwarf Wizard”, “Bruiser of the Ravens”, “Tier 1”. A pack words it as headline, an ICU message, plain text (no Markdown), filled from the character’s sheet. It is optional: without one, or while it words nothing (a sheet with nothing picked yet), an app shows its own fallback, such as the pack’s name.

# 5e-srd-5.1
headline: >-
{has_type_monster, select,
true {{type_monster}, CR {field_challenge_rating_text}}
other {{type_class_count, plural, one {Level {field_level_total}} other {}}
{has_type_subrace, select, true {{type_subrace}} other {{type_race}}}
{type_class_count, plural, one {{type_class}} other {{type_class_each}}}}}
headline.separator: ' / '
types.class.headline: '{name} {self_level}'
# "Level 3 Hill dwarf Wizard", "Hill dwarf Wizard 2 / Fighter 1", "Goblin, CR 1/4", "Dwarf"

A headline’s arguments:

Argument Value
{field_<field>} A field’s value, its ID spelled with _ for . and - (field_level_total, field_aspects_highConcept), as a tag test spells a tag; a set is a list
{field_<field>_name} The value’s name: a content item’s, a field’s or a tag’s name, else the value
{field_<field>_text} The value as the sheet shows it: a number in its display hint’s format (signed, percent, fraction: “1/4”) and with its unit’s short name; else as _name
{type_<type>} The names of the character’s content of a type, as a content group of the type lists it: each copy (with its templates) and each picked or granted item, in activation order
{type_<type>_count} How many there are
{type_<type>_each} Each worded by its headline piece (below), else by its name
{link_<slot>} The name of the character filling a link slot, which the app gives (the format doesn’t name characters)
{has_<argument>} true when the argument has a value (not absent, not empty), else false, for select
  • Which headline: the last of the character’s packs (in its packs order, homebrew last) that has headline. A field or content type is looked up in that pack first, then in the character’s other packs in order.
  • Missing values are the empty string, as in a block: a part that needs a value tests has_ first. A count of none is 0.
  • Lists are joined by the pack’s headline.separator (” / “), else the language’s list format (“Wizard, Fighter”).
  • Spaces: the result is trimmed and each run of white space is one space, so a template may leave a missing part’s space in place (“{type_race} {type_class}” is “Dwarf” before a class).
  • A headline piece words one item for {type_<type>_each}: types.<type>.headline for every item of a type, in the type’s pack, or <contentId>.headline for one item (it replaces the type’s: MÖRK BORG’s “No class” item is “Classless”). Its arguments are a block’s (Blocks): {name}, the item’s fields, and for a copy its state and computed values ({self_level}). Plain text, like the headline.

pnpm validate checks a headline: it parses as ICU, and each argument names a field, a content type or a link slot the pack sees (one field per spelling). A headline piece is checked as a block is.

  • Two packages per pack: the mechanics package also contains the default language, and one -i18n package holds all other languages. Translation fixes never change the mechanics pack’s version.
  • Text isn’t saved state, so it isn’t pinned. Because keys are permanent, the latest translations work with older pack versions.
  • Every language, including the default, is its own entry point, so apps ship only the languages they import.
  • Fallback: a missing translation falls back to the default language, if the app has loaded it.
  • Translating copyrighted text is still copying it, so a pack’s license applies to its translations too.

How the build packages translations (per-language entry points, the -i18n package) is in packs.md.

The engine never outputs display text. It outputs keys, codes and data, and the app renders them in the player’s language. The spec ships standard English text for its own error and rules-issue codes in ../i18n/en.yaml, under errors.<code> and issues.<code>, with placeholders filled from the error’s data, for the options it reserves (actions.options.done.name), for a roll summary’s comparison codes (compare.lt, compare.le, compare.gt, compare.ge, compare.eq, with the placeholder {value}), and for a roll’s dice: dice.dice ({count}, {sides}) and dice.fate for a dice item, one dice.<modifier> per entry of dice-modifiers.json ({count}, {target}, or {faces}, the faces it matches as compare.<code> text or the face itself), added with dice.modified, and items joined with dice.plus, dice.minus and dice.negative. Every code in the fixed sets has its text; errors.other ({code}) words a code an app has no text for (an engine newer than its text), so a player never sees a bare code.

Turning keys into text is the app’s. The reference helper @opencharacter/text builds every key above from engine output and formats it; it is not part of conformance.

The spec package exports that text as packs export their default language (packs.md): @opencharacter/spec/i18n/en is a JavaScript module whose default export is the flat map of keys to messages, with the same map as JSON for other runtimes (require, or dist/i18n/en.json in the package). The YAML source stays available as @opencharacter/spec/i18n/en.yaml.