Skip to content

Building characters

Character creation and levelling up are mostly choices: a class, a playbook, skills, a fighting style, cantrips, a subclass at level 3. The packs declare them, the engine says which are waiting for an answer and which options are eligible, and your app decides how to ask.

Every game can be created the same three ways, and the pack says which it supports. The ruleset lists its creation starts in ruleset.creation:

"creation": [
{ "id": "classless", "action": "mork-borg-core:create-character", "params": { "classless": true }, "random": true, "pack": "mork-borg-core" },
{ "id": "class", "action": "mork-borg-core:create-character", "random": true, "pack": "mork-borg-core" }
]

Name each start with creation.<id>.name in its pack’s text. For a new character, make the file with creating: true, then offer:

Mode Offer it for Run
Random a start with random the start’s action, policy { dice: auto, choices: auto, allocations: auto }
Guided every start the same, policy { dice: pause, choices: pause, allocations: pause }
Manual always nothing: the player fills in the blank sheet
const character = { specVersion: '0.1.0', id: newId(), packs, creating: true };
const start = ruleset.creation[0];
let run = startAction(ruleset, character, {
action: { id: start.action },
params: start.params,
policy: { dice: 'pause', choices: 'pause', allocations: 'pause' }, // guided
});
// Put each pause to the player and continueAction with the answer, as in Rolling dice.

A guided run is a wizard: each pause is a question. Besides dice and choices, creation uses allocations (needs.kind: 'allocate'): spend a budget across fields (FitD’s four action dots, 5E point buy) or hand out a list of values (5E’s standard array, Fate’s skill pyramid). needs.allocate lists the fields, and the budget or the values. Each field has its computed value and its input value, before effects, so you can show “8 + 7 point buy + 2 racial”; on a budget it also has its limit and costs, the cost of each amount from 0, so the points left are budget less the sum of costs[amount] (5E’s point buy: [0, 1, 2, 3, 4, 5, 7, 9]). Answer with { amounts }, a map of field ID to amount:

continueAction(ruleset, run, {
amounts: {
'fitd-scoundrel:ratings.prowl': 2,
'fitd-scoundrel:ratings.survey': 1,
'fitd-scoundrel:ratings.command': 1,
},
});

A creation action can also ask the picks the content it adds brings (5E’s skills and fighting style, a Mothership class’s skills, FitD’s Veteran), one choice at a time. Such a pause carries needs.choice: the choice’s path, owner and id, and its min, max and picked so far, for “pick 2 of 6”. Name the question with the choice’s own text, <owner>.choices.<id>.prompt, and the options as you name listOptions results. #done ends the whole step, not just this choice: every choice still pending is left for later, so label it “Skip the rest” (actions.options.done.pending.name).

The finished run proposes the changes that build the character, ending with finishCreation. Applying them is the player’s confirmation. finishCreation clears creating, unless something is still missing: then the batch fails with creation-incomplete, and the result’s issues list what’s missing (a pending choice as too-few-picks). Apply the other changes on their own, let the player resolve what’s listed (often the picks the added class or race brings), and apply { type: 'finishCreation' } when they’re done. A character made by hand finishes the same way.

While creating is true, the pack’s creation-only rules apply as ordinary rules issues (FitD: no action above 2; Fate: the skill pyramid), so a manual builder shows them like any other.

  • Numbers the player supplies (ability scores, rolled hit points): set them. On a sheet filled in by hand, the engine checks only that the value fits the field (a whole number from 1 to 30), plus any creation-only constraint the pack has. A pack’s creation action may offer generation methods (5E’s offers 4d6 drop lowest, point buy and the standard array); your app can offer others (“my GM said 18”).
  • Content choices (a class, skills, a feat): go through the engine, as below.

Input fields, with their types and bounds, are in ruleset.fields (input: true). The pack’s display hints often give an input widget too (Showing a sheet).

Some content is added, not chosen. In 5E, a class is a copy in the character’s features, with its level as state:

applyChanges(ruleset, character, [
{
type: 'addItem',
entry: {
id: 'fighter',
item: '5e-srd-5.1:classes.fighter',
state: { level: 1, startingClass: true },
},
},
]);

What to offer here (every class) is a catalog, not a choice; see Listing content. Other games declare their starting picks as rules-level choices instead: a new FitD scoundrel has a pending special-abilities and vice right away (and an optional playbook when the table has its own).

Which content a game adds and which it chooses is the pack’s design. An any-game builder works from pending alone, and adds a “add content” screen per content type for the rest.

After every change, computeSheet lists the choices that apply now and still need picks:

"pending": [
{ "path": { "entry": "fighter", "choice": ["fighting-style"] }, "min": 1, "max": 1, "picked": 0 },
{ "path": { "entry": "fighter", "choice": ["skills"] }, "min": 2, "max": 2, "picked": 0 }
]
  • path says where the choice lives: on an entry (entry), or at the rules level (no entry), then the choice ID. A nested choice continues the path through the picked option: { choice: ['playbook', 'homebrew:playbooks.bruiser', 'special-ability'] } is the special ability of a table’s own playbook (the Bruiser of fitd-ash.yaml), under the playbook choice.
  • min, max and picked say how many picks it needs and has.
  • A choice ID with a slash (5e-srd-5.1:features.wizard.spellcasting/cantrips) belongs to content granted to the entry rather than to the entry’s own item.

The prompt text comes from the choice’s owner (Text and translation).

Choices can depend on level (“at level 3”), so new ones appear as the character grows. A choice with required: false in the pack is never pending; your app can still offer it.

A wizard with Previous and Next needs the answered steps too. sheet.choices lists every choice that applies, in the same order as pending, answered or not:

"choices": [
{
"path": { "entry": "class", "choice": ["bonus-skills"] },
"owner": "mothership-1e:classes.marine", "id": "bonus-skills",
"min": 1, "max": 1, "picks": ["trained"], "open": false
},
{
"path": { "entry": "class", "choice": ["bonus-skills", "trained", "trained"] },
"owner": "mothership-1e:classes.marine.choices.bonus-skills.options.trained", "id": "trained",
"min": 2, "max": 2, "picks": ["mothership-1e:skills.computers"], "open": true
}
]
  • picks are the options picked so far; open is true for the entries that are in pending.
  • owner and id name the prompt: <owner>.choices.<id>.prompt in the owner’s pack (classes.marine.choices.bonus-skills.options.trained.choices.trained.prompt). A slot has slot: { choice, number }: its prompt is under slot.choice, with {slot} filled in.
  • Inline options (the Marine’s “two Trained skills”) and picks that are references only (a bard’s instruments) are listed here, though activations doesn’t list them.

Show each entry as a step. To change an answer, unchoose and choose in one batch; the next sheet lists the new answer’s own choices where the old answer’s were (after the choices of the content that asks it).

const result = listOptions(ruleset, character, { entry: 'fighter', choice: ['fighting-style'] });
// { options: [{ option, eligible, picked, recommended, reasons }, …], min: 1, max: 1 }

listOptions returns every option, including ineligible ones:

  • eligible is false when picking it would cause rules issues, and reasons lists them. They’re exactly the issues applyChanges would report if the player picked it anyway, so “needs Strength 13” reads the same in the picker and on the sheet.
  • picked marks options already picked (useful when re-opening a choice to change it).
  • recommended marks the option the pack recommends, if any.

Whether to hide, grey out or allow ineligible options is your call. A strict builder hides them; a table that bends rules shows them with their reasons.

Options are content IDs (translate with <id>.name), inline option IDs (key <owner>.choices.<choice>.options.<id>.name), or plain values. A value may have text under the same key; without it, name the value yourself (a content ID by its item’s name, a field ID by the field’s). A value choice can depend on the character: 5E’s Expertise lists every skill and marks the ones the character isn’t proficient in ineligible, and a FitD scoundrel’s crew dots come from the linked crew sheet, so pass the links to listOptions too.

Some content choices pick a reference rather than something the character has: a bard’s three instruments are proficiencies, not instruments carried, so they add no actions or effects of their own.

An unknown path returns { errors: [{ code: 'unknown-choice' }] }.

applyChanges(ruleset, character, [
{ type: 'choose', path: { entry: 'fighter', choice: ['skills'] }, option: 'skill.athletics' },
{ type: 'choose', path: { entry: 'fighter', choice: ['skills'] }, option: 'skill.perception' },
{
type: 'choose',
path: { entry: 'fighter', choice: ['fighting-style'] },
option: '5e-srd-5.1:fighting-styles.dueling',
},
]);

One choose change per pick. Picking an option can make new choices pending (a picked playbook brings its special abilities), so compute the sheet again after each batch and keep going until pending is empty, or the player stops.

unchoose removes a pick and every pick nested under it, in the same all-or-nothing change. Changing a pick is unchoose then choose in one batch.

A level-up is ordinary changes in one batch. In 5E it raises a class copy’s level state:

applyChanges(ruleset, character, [{ type: 'adjustState', entry: 'fighter', key: 'level', by: 1 }]);

Then compute the sheet: at fighter level 3, martial-archetype becomes pending. Put the new pending choices to the player, and apply the level change and the picks together if you want the whole level-up to be one undo step. 5E’s level-up action does this for you: after the level and hit points it asks the choices the new level brings, as a creation action does.

Multiclassing is another class copy (addItem with level 1). A multiclass prerequisite the character doesn’t meet comes back as an issue, not an error:

{
"code": "prerequisite-not-met",
"id": "5e-srd-5.1:classes.fighter/multiclass",
"source": { "type": "item", "id": "5e-srd-5.1:classes.fighter", "entry": "fighter" }
}

Legality is checked against the current character only. If Strength later drops below 13, the same issue appears, as if the class had been taken illegally. The engine has no history to consult, and that’s on purpose.

  • Buying or finding gear is addItem with a new instance id. Carried content goes to inventory, the rest to features; the engine picks from the content type.
  • Equipping is equip. Effects that only apply while equipped (when: self.equipped) follow.
  • Per-copy state (charges, attunement, a lit Flame Tongue) is setState/adjustState on the entry. The state keys are declared by the content type, the item and its templates.
  • Magic properties like +1 are templates applied to a copy (applyTemplate). To offer them, list the templates in the ruleset (content of type template) whose appliesTo matches the item; applyChanges rejects one that doesn’t apply with template-not-applicable. A template can set fields too (a rarity, a bonus, a Sun Blade’s radiant damage); to show a copy’s fields, read the ruleset’s templateFields and merge each template’s fields over the base item’s in the copy’s order, as the engine does for item.