Skip to main content

Code frameworks

Recipe: roll against a skill

Check a percentile roll against a skill value and report a success band.

Goal

Let a player roll a d100 against a Lore skill and see a band: Flawless, Strong, Pass or Miss. A lower roll is better.

How the roll gets in

Rules cannot roll dice. The player rolls on the sheet and types the number into an action. The rule validates the number and writes it. The play loop has four steps.

  1. Click the dice badge

    The sheet shows Roll: [[1d100]]. A click rolls a d100.

  2. Read the roll in the log

    The result appears in the table's dice log and chat. Nothing is written to the character yet.

  3. Run the action and type the roll

    Use the Lore check button. Fabled asks for the roll in a dialog. Type the number from the log.

  4. The band updates

    The rule writes the roll and the target. The ledger function derives the band, and the sheet shows it.

The change

Add a Lore input, the action and the ledger function to the schema. A constructor input can have bounds.

schemas/keeper.json
    "lore": {
      "label": "Lore",
      "description": "The percentile a Lore check must roll under.",
      "defaultValue": 40,
      "min": 1,
      "max": 99
    }
  1. Lines 13-14 These bounds apply when a character is made. They do not apply to the roll.
schemas/keeper.json
    {
      "id": "check_lore",
      "name": "Lore check",
      "source": "rules/check-lore.ts",
      "inputs": { "roll": { "label": "Roll", "min": 1, "max": 100 } }
    }
  1. Lines 21-23 The Lore check action and its rules file.
  2. Line 24 The Roll input: a label and bounds. The dialog shows them and the server enforces them.
schemas/keeper.json
  "ledgerFunctions": [
    {
      "id": "last_band",
      "name": "Last band",
      "field": "last_band",
      "source": "rules/band.ts"
    }
  ]
  1. Lines 32-35 The ledger function derives last_band from the two fields the action writes.

The constructor returns the new fields with placeholders. The ledger function replaces last_band when the Element is built.

rules/keeper.ts
export function construct(
  name: string,
  max_hp: number,
  lore: number,
): {
  name: string;
  max_hp: number;
  hp: number;
  lore: number;
  last_roll: number;
  last_target: number;
  last_band: string;
} {
  return {
    name: name,
    max_hp: max_hp,
    hp: max_hp,
    lore: lore,
    last_roll: 0,
    last_target: 0,
    last_band: "",
  };
}
  1. Lines 10-12 The fields the action writes and the ledger function reads.
  2. Lines 19-21 Starting values: no roll yet.

The input bounds keep the roll between 1 and 100. The action ignores a number with decimals and otherwise writes the roll and the target.

rules/check-lore.ts
export function check_lore(self: ElementRef, roll: number): void {
  if (math.floor(roll) === roll) {
    const lore: number = element.var(self, "lore") as number;
    ledger.set(self, "last_target", lore);
    ledger.set(self, "last_roll", roll);
  }
}
  1. Line 1 The roll is an input the player types.
  2. Line 2 Ignore a number with decimals. The input bounds already keep it between 1 and 100.
  3. Lines 4-5 Write the target and the roll. The band follows from them.

One ledger function turns them into a band. Every skill can use it, because every skill action writes the same two fields.

rules/band.ts
export function last_band(currentValue: string, self: ElementRef): string {
  const roll: number = element.var(self, "last_roll") as number;
  const target: number = element.var(self, "last_target") as number;
  if (roll === 0) {
    return "No roll yet";
  }
  if (roll <= math.floor(target / 5)) {
    return "Flawless";
  }
  if (roll <= math.floor(target / 2)) {
    return "Strong";
  }
  if (roll <= target) {
    return "Pass";
  }
  return "Miss";
}
  1. Lines 2-3 Read the two fields the action wrote.
  2. Lines 4-6 Before any roll, say so.
  3. Lines 7-15 A lower roll is better. Each band is a share of the target.
  4. Line 16 Above the target is a miss.

For another skill, copy check-lore.ts, change the function name and the field in the element.var call. Each skill needs its own action, because a field name is written out in the code.

Show the dice, the band and a button on the sheet. List the band in requiredBindings and the action in the aliases.

layouts/keeper.json
      { "binding": "element.attributes.last_band", "expectedType": "string" }
  1. Line 15 The band is text.
layouts/keeper.json
      { "name": "check_lore", "functionRef": "check_lore" }
  1. Line 21 The alias the button uses.
layouts/keeper.json
      { "kind": "text", "text": "Roll: [[1d100]]" },
      {
        "kind": "text",
        "text": "Last check: {{element.attributes.last_band}}"
      },
      { "kind": "button", "label": "Lore check", "actionRef": "check_lore" }
  1. Line 35 The dice badge. A click rolls and shows the result in the dice log.
  2. Lines 36-39 The band that the ledger function derived.
  3. Line 40 The button that opens the dialog for the roll.

Test it

Test each band, and a roll outside the range. Each step lists the two writes. expectedValuesAfter checks the band after the action.

scenarios/lore.json
    {
      "action": "check_lore",
      "parameters": { "roll": 6 },
      "expectedWrites": [
        { "op": "ledger.set", "key": "last_target", "value": 40 },
        { "op": "ledger.set", "key": "last_roll", "value": 6 }
      ],
      "expectedValuesAfter": { "last_band": "Flawless" }
    },
  1. Line 17 The number the player typed.
  2. Lines 18-21 The two writes, in order.
  3. Line 22 The derived band after the action.

The last step is a roll of 40.5. It writes nothing, so the decimals guard is proven. A roll of 500 cannot be a step: it is outside the input bounds, and the scenario is refused.

scenarios/lore.json
    {
      "action": "check_lore",
      "parameters": { "roll": 40.5 },
      "expectedWrites": [],
      "expectedValuesAfter": { "last_band": "Miss" }
    }
  1. Line 53 A number with decimals.
  2. Line 54 No writes.
  3. Line 55 The band stays as the earlier step left it.

List the scenario file in the manifest. Add the new fields to the expected values of the other scenarios.

manifest/project.json
{
  "formatVersion": "fabled.game-project/v1",
  "projectId": "game-your-game-id",
  "projectKind": "GAME",
  "compilerProfile": "native-game-v1",
  "name": "Lantern Keepers",
  "entryFramework": "keeper",
  "frameworks": ["schemas/keeper.json"],
  "scenarios": [
    "scenarios/new-keeper.json",
    "scenarios/heal.json",
    "scenarios/lore.json"
  ]
}
  1. Line 12 The new scenario file.

Next