Text and translation
The engine never returns display text. It returns IDs, keys, codes and data, and your app turns them into text in the player’s language. This page explains the keys; the Showing text helper builds them for you. That’s what lets one engine serve every language, and it lets packs keep rules text separate from mechanics.
Where text lives
Section titled “Where text lives”Each pack has one message file per language: a flat map from key to an ICU MessageFormat message.
# 5e-srd-5.1, Englishfields.combat.ac.name: 'Armor Class'fields.combat.ac.short: 'AC'armor.shield.name: 'Shield'magic-items.weapon-plus-one.name: '{base} +1'constraints.attunement-limit: 'A creature can be attuned to at most {limit} magic items (currently {attuned}).'-
The pack’s default language ships with the pack (
@opencharacter/5e-srd-5.1/i18n/en). -
Other languages ship in the pack’s
-i18npackage (@opencharacter/5e-srd-5.1-i18n/de). Translation fixes don’t change the pack’s version, so characters never see “update available” for a typo. -
Every language is its own entry point. Import only the ones you show, or load them on demand:
import en from '@opencharacter/5e-srd-5.1/i18n/en';const de = await import('@opencharacter/5e-srd-5.1-i18n/de'); -
Text isn’t pinned. Keys are permanent, so the latest translations work with any version of the pack. Load the newest language files you have.
-
Fallback: when a key is missing in the player’s language, use the pack’s default language. The default-language file has every required key; other languages may be partial.
-
Some packs have no rules text on purpose. A
mechanics-onlypack (Mothership) has names and neutral labels only, and its descriptions are for players or licensed packs to supply.
Keep messages per pack: keys are local to their pack, so two packs can both have
fields.hp.max.name.
type Messages = Record<string, string>;const text: Record<string, Messages[]> = { // pack ID → message files, player's language first, default language last '5e-srd-5.1': [de, en],};
function message(pack: string, key: string): string | undefined { for (const messages of text[pack] ?? []) if (key in messages) return messages[key]; return undefined;}From an ID to a key
Section titled “From an ID to a key”The engine hands you qualified IDs (5e-srd-5.1:armor.shield). Split off the pack, then
build the key from the local part:
function split(qid: string): [pack: string, local: string] { const i = qid.indexOf(':'); return [qid.slice(0, i), qid.slice(i + 1)];}
const contentName = (qid: string) => { const [pack, local] = split(qid); return message(pack, `${local}.name`); // armor.shield.name → Shield};
const fieldName = (qid: string) => { const [pack, local] = split(qid); return message(pack, `fields.${local}.name`); // fields.combat.ac.name → Armor Class};
function actionName(qid: string) { const [pack, local] = split(qid); const hash = local.indexOf('#'); // an action defined on an item: <item>#<action> return hash < 0 ? message(pack, `actions.${local}.name`) : message(pack, `${local.slice(0, hash)}.actions.${local.slice(hash + 1)}.name`);}The key conventions (Translation):
| What | The engine gives you | Key, in the ID’s pack |
|---|---|---|
| The pack | pack.id |
pack.name |
| A content item | 5e-srd-5.1:armor.shield |
armor.shield.name, armor.shield.description |
| A field | 5e-srd-5.1:combat.ac |
fields.combat.ac.name, fields.combat.ac.short |
| A content type | 5e-srd-5.1:weapon |
types.weapon.name |
| A display group | group combat (with its pack) |
groups.combat.label |
| A rules action | 5e-srd-5.1:weapon-attack |
actions.weapon-attack.name |
| An action on an item | 5e-srd-5.1:features.fighter.second-wind#second-wind |
features.fighter.second-wind.actions.second-wind.name |
| An action’s choice option | option hit of weapon-attack |
actions.weapon-attack.options.hit.name |
| An action’s choice question | needs { action, step: 'hit/confirm' } |
actions.weapon-attack.steps.confirm.prompt |
| An action’s roll (optional) | needs or log { action, step: 'damage' } |
actions.weapon-attack.steps.damage.name |
| An action’s parameter | parameter targetAc of weapon-attack |
actions.weapon-attack.params.targetAc.name |
| An action’s result | outcome.result: hit of weapon-attack |
actions.weapon-attack.results.hit.name |
| Longer text (optional) | an action, a field | actions.weapon-attack.description, fields.hp.current.description |
| A situational choice | 5e-srd-5.1:advantage |
situational.advantage.name |
| A result band | log band: success, bandSet: …:action |
bands.action.success.name |
| A named text (optional) | a spell’s type declares texts: [material, trigger] |
spells.shield.texts.trigger |
| A copy’s value (optional) | a cohort’s computed quality, its state harm |
types.cohort.computed.quality.name, types.cohort.state.harm.name |
| A tag (optional) | tag finesse on a weapon |
tags.finesse.name, tags.finesse.description |
| A card header (optional) | a spell, 5e-srd-5.1:spells.fire-bolt |
spells.fire-bolt.block, else types.spell.block |
| An enum value (optional) | a spell’s school: evocation |
types.spell.fields.school.values.evocation.name |
| The headline (optional) | the character, “Level 3 Hill dwarf Wizard” | headline; a member’s piece types.class.headline |
| A choice on content | path { entry, choice: ['fighting-style'] } on a fighter |
classes.fighter.choices.fighting-style.prompt |
| A rules-level choice | path { choice: ['playbook'] } |
rules.choices.playbook.prompt |
A prerequisite (issue id) |
5e-srd-5.1:classes.fighter/multiclass |
classes.fighter.prerequisites.multiclass |
A constraint (issue id) |
5e-srd-5.1:attunement-limit |
constraints.attunement-limit |
Some details:
- Display hints can override keys. A field hint’s
labelorshortLabel, and a group’slabel, name a different key in the same pack. Use it when present. - Choice prompts belong to the choice’s owner. In a path, the owner of a choice is the
option just before it, or the entry’s item when the choice comes first, or the rules when there
is no entry. In
{ choice: ['playbook', 'homebrew:playbooks.bruiser', 'special-ability'] }the owner ofspecial-abilityis the Bruiser, a table’s own playbook, so the key isplaybooks.bruiser.choices.special-ability.promptin its homebrew text. A choice ID with a slash names its owner itself:5e-srd-5.1:features.wizard.spellcasting/cantrips(a choice of granted content) isfeatures.wizard.spellcasting.choices.cantrips.prompt. A rules-level choice’s ID in a path is bare; find its pack fromruleset.rules.choices, whose keys are qualified. - Result bands: the log entry of a roll gives the band ID (
band) and the band set’s qualified ID (bandSet); the key is in the set’s pack. - Descriptions are Markdown (CommonMark with GFM tables, never HTML). Render them, or show them as plain text. Every other message is plain text.
- Named texts are extra text a content type declares (
textsinruleset.contentTypes): 5E spells havematerial(the material component) andtrigger(a reaction’s trigger), for the spell’s header. An item may have none. - Blocks are an item’s card header, an ICU template over its fields in Markdown
(Translation): show the block, then the
description. Fill one with the item’s fields by name,
{<field>_name}the value’s name,{has_<field>}whether it has one,{texts_<text>}and{self_<key>}a copy’s state;@opencharacter/textdoes it for you (Showing text). - The headline is a one-line description of the character, the pack’s
headlinetemplate (Translation): show it under the character’s name, and the pack’s name when the pack has none or it words nothing yet.@opencharacter/textfills it (Showing text). - Tags may have a name and a description in the pack that declares them (5E’s weapon properties). A tag without a name is for the rules, not for players.
- Steps of an action belong to the action that defines them. A pause and each log entry give
action(the defining action’s ID) andstep(a path). Build the step’s keys fromactionand the path’s last segment: a nested panic check’s pause isstep: 'panic/panic',action: 'mothership-1e:panic-check', so its keys start withactions.panic-check.steps.panic. - Action results: an action’s
results(inruleset.rules.actions[id]) lists its results, each named<action key>.results.<result>.name;results: { bands: <set> }means the results are that set’s bands, named like any band. A step’spromptorlabel, or a parameter’slabel, overrides the default key. - Hidden fields have no names. Don’t show them.
Templated names
Section titled “Templated names”A template’s name includes its base item’s name as the ICU argument {base}:
magic-items.weapon-plus-one.name: '{base} +1'. For a copy with templates, format each template’s
name in turn, starting with the base item’s:
function copyName(entry: { item: string; templates?: string[] }) { let name = contentName(entry.item) ?? entry.item; for (const t of entry.templates ?? []) name = format(templateMessage(t), { base: name }); return name; // Longsword +1}Use an ICU MessageFormat library to format messages: intl-messageformat in JavaScript, and ICU
libraries exist for most other languages. Messages also use plurals
({count, plural, one {# charge} other {# charges}}).
Errors and issues
Section titled “Errors and issues”Error and rules-issue codes are defined by the spec, not by packs, and the spec ships English text for each of them (source). Import it as you import a pack’s default language, a flat map of keys to messages:
import specText from '@opencharacter/spec/i18n/en'; // or the JSON: dist/i18n/en.jsonerrors.wrong-type: '"{target}" needs a value of type {expected}.'errors.not-owned: 'The character does not have "{entry}".'issues.too-many-picks: 'Too many options are picked (at most {max}).'issues.prerequisite-not-met: 'A requirement is not met.'Fill the placeholders from the error’s or issue’s data. For example:
{ "code": "wrong-type", "path": "/changes/0", "data": { "target": "5e-srd-5.1:abilities.str.base", "expected": "a whole number" }}target is a field ID here, so a good message replaces it with the field’s name before
formatting.
Prefer the pack’s text for an issue when there is one. prerequisite-not-met and
constraint-failed carry an id, and the pack has a specific message for it (the keys in the
table above). The spec’s generic text is the fallback:
function issueText(issue: { code: string; id?: string; data?: Record<string, unknown> }) { if (issue.id && issue.code === 'prerequisite-not-met') { const [pack, local] = split(issue.id); const [owner, prerequisite] = local.split('/'); const own = message(pack, `${owner}.prerequisites.${prerequisite}`); if (own) return own; // Multiclassing into fighter needs Strength 13 or Dexterity 13. } if (issue.id && issue.code === 'constraint-failed') { const [pack, local] = split(issue.id); const own = message(pack, `constraints.${local}`); if (own) return format(own, issue.data ?? {}); // at most 3 magic items (currently 4) } return format(specText[`issues.${issue.code}`], issue.data ?? {});}The spec only ships English for its codes for now. Translate the errors.* and issues.* keys
into your app’s other languages yourself.
Credit lines
Section titled “Credit lines”A pack’s credit line (ruleset.packs[].credit) and its notices (ruleset.packs[].notices) are
legal text, not translation keys. Show them as written (Packs and rulesets).
- Showing text: a helper that builds these keys and formats them for you.
- Changing characters.