Skip to main content

Code frameworks

Pools and modifiers

Share a point budget between inputs, and let content change numbers on the character.

Pools

A pool limits a group of number inputs that a player fills together, such as three traits that share a point budget. The server checks every pool when a character is created or edited.

schemas/keeper.json
  "pools": {
    "traits": {
      "label": "Traits",
      "inputs": ["might", "wit", "grace"],
      "methods": {
        "pointBuy": {
          "budget": 6,
          "min": 1,
          "max": 5,
          "costs": { "1": 0, "2": 1, "3": 2, "4": 4, "5": 6 }
        }
      }
    }
  }
  1. Line 23 The number inputs of the pool. Each needs an entry under inputs.
  2. Line 24 The methods the player may use.
  3. Line 26 The total cost of the scores may not pass the budget.
  4. Line 27 Every score is at least this.
  5. Line 28 Every score is at most this.
  6. Line 29 The cost of each score. Every score from min to max needs one.
MethodWhat it checks
standardArrayThe scores are the listed values, in any order.
pointBuyEvery score is between min and max, and the costs add up to at most the budget.
manualEvery score is between min and max.
rollThe server rolls count scores of dice, adds the optional bonus, multiplies by the optional multiplier, and the player assigns the totals to the inputs.
budgetEvery score is between min and max, and the scores add up to at most total.

A pool must declare at least one method. A pool with more than one method names the input that picks the method in methodInput. Presets and scenarios must satisfy the pool, and a scenario shows it:

scenarios/new-keeper.json
{
  "id": "new-keeper",
  "framework": "keeper",
  "parameters": {
    "name": "Mira",
    "max_hp": 10,
    "might": 3,
    "wit": 2,
    "grace": 1
  },
  "expectedValues": {
    "name": "Mira",
    "max_hp": 10,
    "hp": 10,
    "might": 3,
    "wit": 2,
    "grace": 1
  }
}
  1. Lines 7-9 These scores cost 2, 1 and 0, so the total is 3 and the budget is 6.
is not satisfied: pointBuy.budget
pointBuy costs has no entry for 5
A pool declares at least one method.
LimitValue
Pools per Framework4
Inputs per pool64
Values between min and max64
Budget1000

The budget method takes total, min and max. Every score is a whole number from min to max, and the scores add up to at most total. The scores are not priced, so it suits a skill-point budget and needs no costs.

Rolled scores

The roll method lets the server roll the scores, so a player cannot type their own. It takes dice and count, and two optional keys, bonus and multiplier. dice is one group of dice, such as "3d6" or "4d6dl1". count is how many scores the server rolls, and it equals the number of inputs in the pool. bonus (-20 to 100) is added to each dice total, then multiplier (1 to 10) multiplies it, so each score is (dice total + bonus) × multiplier. A 3d6 × 5 characteristic, as in Call of Cthulhu, is dice "3d6" with multiplier 5.

schemas/keeper.json
  "pools": {
    "traits": {
      "label": "Traits",
      "inputs": ["might", "wit", "grace"],
      "methodInput": "method",
      "methods": {
        "manual": { "min": 15, "max": 90 },
        "roll": { "dice": "3d6", "count": 3, "multiplier": 5 }
      }
    }
  }
  1. Line 29 A pool with more than one method names the input that picks the method.
  2. Line 31 The player may type the scores.
  3. Line 32 Or the server rolls 3 scores of 3d6, one for each input.

methodInput names the string input that picks which method a pool uses. The player's choice in that input decides which method the server checks, so the same scores can be held to the standard array, to point buy or to a roll. A pool with one method needs no methodInput and always uses that method. With more than one method it is required, and the input's options must be exactly the method names of the pool. A scenario or preset sets the input to one of the names. A value that is missing or not a method name fails with method.missing or method.unknown.

schemas/keeper.json
    "method": {
      "label": "Scores by",
      "options": ["manual", "roll"],
      "defaultValue": "manual"
    },
  1. Line 11 Exactly the methods of the pool, nothing else.

Every total the roll can give, after the bonus and multiplier, must fit the min and max of each input. 3d6 × 5 gives 15 to 90, so each trait allows 15 to 90. The dice are one group: up to 10 dice with up to 100 sides, and dl, dh, kl or kh drops or keeps 1 to 9 of them, with at least one die kept. Write a multiplier with the multiplier key, never inside the dice: 3d6x5 is refused.

The roll dice must be one group such as
outside the min/max of input
give exactly one roll per input.

A key that is missing shows as a JSON list. The path in the list names the key, here pools, traits, methods, roll, dice, and the message says Required.

Required

In the character creation form, a roll pool shows a Roll scores button in place of its inputs, and Reroll all after the first roll. The server rolls and keeps a record, so the browser never rolls. The player then assigns the totals to the inputs in any order. Rolling is offered only in the creation form.

A scenario that picks the roll method supplies the rolls itself in poolRolls, because the real rolls exist only on the server. poolRolls maps a pool id to one slot for each rolled score. A slot has a total, the final number after the bonus and multiplier, and optionally dice and dropped, which are only for reading. The pool check then treats the slots as the server's record, so the scenario's scores must be those totals, in any order. Each total must be one the roll can produce. Without poolRolls, a scenario that picks roll fails with roll.missing. A preset cannot use the roll method. The creation form tests the real roll.

scenarios/new-keeper.json
{
  "id": "new-keeper",
  "framework": "keeper",
  "parameters": {
    "name": "Mira",
    "max_hp": 10,
    "method": "roll",
    "might": 60,
    "wit": 45,
    "grace": 75
  },
  "expectedValues": {
    "name": "Mira",
    "max_hp": 10,
    "hp": 10,
    "might": 60,
    "wit": 45,
    "grace": 75,
    "method": "roll"
  },
  "poolRolls": {
    "traits": [
      { "total": 45, "dice": [3, 4, 2] },
      { "total": 60, "dice": [5, 4, 3] },
      { "total": 75, "dice": [6, 5, 4] }
    ]
  }
}
  1. Line 7 The scenario picks the roll method.
  2. Lines 21-27 The recorded rolls. Each total is the dice sum times the multiplier, here 9 × 5 = 45.
is not satisfied: roll.missing

Modifiers and effects

A modifier is a number that the platform derives from the content a character holds. A list that applies effects feeds it. Your rules read it like a field. This example lets worn charms raise Guard.

The character Framework declares the modifier.

schemas/keeper.json
  "modifiers": {
    "mod_guard": {
      "label": "Guard",
      "stack": "sum",
      "each": { "min": -3, "max": 3 },
      "total": { "min": -5, "max": 5 },
      "appliesTo": ["guard"]
    }
  },
  1. Line 25 The name players see.
  2. Line 26 sum adds every contribution. max takes the largest.
  3. Line 27 The range of one contribution.
  4. Line 28 The range of the total.
  5. Line 29 The ledger field that the modifier feeds. This is a label for people. Your rules do the maths.

The list says that its entries apply effects, and it can limit them.

schemas/keeper.json
  "lists": {
    "worn": {
      "label": "Worn",
      "of": "charm",
      "max": 3,
      "effects": true,
      "distinctBy": "name",
      "caps": [
        { "label": "Rings", "where": { "slot": { "eq": "Ring" } }, "max": 1 }
      ]
    }
  },
  1. Line 37 The entries of this list change modifiers.
  2. Line 38 An entry with the same name as one in the list is refused with SAME_NAME.
  3. Lines 39-41 At most 1 entry with the slot Ring. Another is refused with LIST_CAP.

The content Framework names its effect inputs. Leave their choices and ranges out, because the compiler fills them from the modifiers of the character.

schemas/charm.json
{
  "id": "charm",
  "name": "Charm",
  "elementType": "item",
  "constructor": "rules/charm.ts",
  "inputs": {
    "name": { "label": "Name" },
    "slot": {
      "label": "Slot",
      "options": ["Ring", "Cloak"],
      "defaultValue": "Ring"
    },
    "effect_1": { "label": "Effect" },
    "amount_1": { "label": "Amount" }
  },
  "effects": [{ "target": "effect_1", "amount": "amount_1" }],
  "frames": [
    { "id": "charm-card", "source": "layouts/charm.json", "default": true }
  ],
  "content": [
    {
      "id": "ring-of-guard",
      "name": "Ring of Guard",
      "parameters": {
        "name": "Ring of Guard",
        "slot": "Ring",
        "effect_1": "Guard",
        "amount_1": 2
      }
    },
    {
      "id": "ring-of-calm",
      "name": "Ring of Calm",
      "parameters": {
        "name": "Ring of Calm",
        "slot": "Ring",
        "effect_1": "None",
        "amount_1": 0
      }
    },
    {
      "id": "cloak-of-guard",
      "name": "Cloak of Guard",
      "parameters": {
        "name": "Cloak of Guard",
        "slot": "Cloak",
        "effect_1": "Guard",
        "amount_1": 1
      }
    },
    {
      "id": "cloak-spare",
      "name": "Cloak of Guard",
      "parameters": {
        "name": "Cloak of Guard",
        "slot": "Cloak",
        "effect_1": "Guard",
        "amount_1": 1
      }
    }
  ]
}
  1. Lines 13-14 The effect and its amount have no options or range.
  2. Line 16 One effect: which input names the modifier, and which holds the amount.
  3. Line 27 The preset gives the modifier label and the amount.

A ledger function reads the modifier like any other field.

rules/guard.ts
export function guard(currentValue: number, self: ElementRef): number {
  return 10 + (element.var(self, "mod_guard") as number);
}
  1. Line 2 mod_guard is declared on the Framework, not returned by the constructor.

The scenario proves each case. expectedValuesAfter checks the derived value after a step, and expectedModifiers checks the modifiers at the end.

scenarios/wear.json
{
  "id": "wear-charms",
  "framework": "keeper",
  "parameters": { "name": "Mira", "max_hp": 10 },
  "expectedValues": { "name": "Mira", "max_hp": 10, "hp": 10, "guard": 10 },
  "actions": [
    {
      "action": "wear",
      "parameters": {},
      "choose": { "charm": "ring-of-guard" },
      "expectedWrites": [
        { "op": "list.add", "key": "worn", "value": "ring-of-guard" }
      ],
      "expectedValuesAfter": { "guard": 12 }
    },
    {
      "action": "wear",
      "parameters": {},
      "choose": { "charm": "ring-of-calm" },
      "expectedRefusal": "LIST_CAP",
      "expectedWrites": []
    },
    {
      "action": "wear",
      "parameters": {},
      "choose": { "charm": "cloak-of-guard" },
      "expectedWrites": [
        { "op": "list.add", "key": "worn", "value": "cloak-of-guard" }
      ],
      "expectedValuesAfter": { "guard": 13 }
    },
    {
      "action": "wear",
      "parameters": {},
      "choose": { "charm": "cloak-spare" },
      "expectedRefusal": "SAME_NAME",
      "expectedWrites": []
    },
    {
      "action": "remove_charm",
      "parameters": {},
      "choose": { "charm": "ring-of-guard" },
      "expectedWrites": [
        { "op": "list.remove", "key": "worn", "value": "ring-of-guard" }
      ],
      "expectedValuesAfter": { "guard": 11 }
    }
  ],
  "expectedLists": { "worn": ["cloak-of-guard"] },
  "expectedModifiers": { "mod_guard": 1 }
}
  1. Line 14 Guard 12: 10 plus the ring's 2.
  2. Line 20 A second ring is refused: the cap allows 1.
  3. Line 30 Guard 13 with the cloak's 1.
  4. Line 36 A second cloak with the same name is refused.
  5. Line 46 Guard 11 after the ring comes off.
  6. Line 50 The total that remains is the cloak's 1.
Modifier fieldMeaning
labelThe name that appears in the content's effect choices.
stacksum or max.
each, totalThe range of one contribution and of the total.
appliesToUp to 8 names of the fields the modifier feeds.
appliesWhenA condition, as a label. Your rules enforce it.

Not covered yet

Other advanced parts exist and are not covered here: row lists, content choices and slots, levelling and rebuilds, combat roles, and records catalogues. Each needs its own page.

Next