Skip to main content

Code frameworks

Writing rules

Write constructors, actions and ledger functions in a small, safe subset of TypeScript.

Three kinds of rule file

KindShapeWhat it may do
Constructorexport function construct(...inputs): { fields }Reads its inputs and returns the starting values. It never writes.
Actionexport function id(self: ElementRef, ...inputs): voidChanges values with ledger.set and ledger.add.
Ledger functionexport function id(currentValue: T, self: ElementRef): TReads values and returns a derived value. It never writes.

A constructor file exports one function. An action file or a ledger file may export several, if each export name is an id declared for that file: an action id or a ledger function id. Stored field names use lowercase letters, digits and underscores.

A ledger function can return text too. Type currentValue and the result as string, and give the ledger entry of the scenario a text currentValue.

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. Line 1 Text in, text out.
rules/hurt.ts
export function hurt(self: ElementRef, amount: number): void {
  ledger.add(self, "hp", 0 - amount);
}
  1. Line 1 The Element comes first as self, then the action's own inputs. An action returns void.

A rule file can also hold helper functions. Write them without export and call them by name from the same file. The file still exports exactly one function, or only the actions or ledger functions it declares.

rules/guard.ts
function base_guard(calling: string): number {
  return calling === "Guard" ? 3 : 1;
}

export function guard(currentValue: number, self: ElementRef): number {
  const calling: string = element.var(self, "calling") as string;
  return base_guard(calling);
}
  1. Lines 1-3 A helper: no export, and it is called by name from the same file.
  2. Line 7 The call to the helper.

A rule file cannot import code. An action file exports one function, unless several actions share it. An extra export on its own fails with:

export function hurt(self: ElementRef, amount: number): void {
  ledger.add(self, "hp", 0 - amount);
}
export function hurt_twice(self: ElementRef, amount: number): void {
  ledger.add(self, "hp", 0 - amount - amount);
}
functions but only the action hurt uses it

To share a helper between actions, let those actions share one file. Name each export after the action it runs, declare every export as an action id, and keep the helper without export. The actions may belong to different Frameworks, and one action cannot call another. Every function carries a copy of its whole file in the compiled game, so keep shared files short.

A helper may only compute and return a value. It cannot call ledger.set or ledger.add. A helper that writes is refused, even in a file that actions share:

function wound(self: ElementRef): void {
  ledger.add(self, "hp", -1);
}
effectful action helpers are not supported until call-target provenance is enforced

Let the helper return the value, and let each action do its own write. These two actions share one pure helper:

rules/hp.ts
function clamp(value: number, low: number, high: number): number {
  return math.max(low, math.min(high, value));
}

export function hurt(self: ElementRef, amount: number): void {
  const hp: number = element.var(self, "hp") as number;
  const max_hp: number = element.var(self, "max_hp") as number;
  ledger.set(self, "hp", clamp(hp - amount, 0, max_hp));
}

export function heal(self: ElementRef, amount: number): void {
  const hp: number = element.var(self, "hp") as number;
  const max_hp: number = element.var(self, "max_hp") as number;
  ledger.set(self, "hp", clamp(hp + amount, 0, max_hp));
}
  1. Lines 1-3 A pure helper: no export, and it only returns a value.
  2. Line 8 The action writes the value that the helper returned.
  3. Line 14 The second action reuses the helper.

A constructor that fills child slots returns { values, children } instead of a plain record. The quickstart shows the plain record, and the Framework schemas page shows the child form.

The TypeScript subset

Rules run the same way everywhere, and they always finish. That is why the language is small.

AllowedNot allowed
Explicit types, const and letimport, classes, arrow functions, new, this
if and else, for of, returnwhile loops, for (;;), switch, break, continue, async code, exceptions
Number, string, boolean, record and array literalsTemplate strings, spread, optional chaining, ??, block comments (use //)
Arithmetic, strict comparison (=== and !==), boolean operators, ternaries== and !=, **, ++ and +=, typeof, changing objects in place, default parameter values
Calls to host functions, helper functions of the same file, and as casts of values they returnMethod calls such as "a".toUpperCase(), Math, Date, JSON, fetch and other globals

End every statement with a semicolon. Each of these mistakes is caught when you check the project.

export function heal(self: ElementRef, amount: number): void {
  while (amount > 0) {
    ledger.add(self, "hp", 1);
    amount = amount - 1;
  }
}
while loops are not allowed
export function heal(self: ElementRef, amount: number): void {
  ledger.set(self, "hp", Math.min(10, amount));
}
'Math' is a host global and not part of the TypeScript subset
import { clamp } from "./clamp";
import is not allowed

A function with a return type must return a value on every path.

export function hurt(self: ElementRef, amount: number): number {
  ledger.add(self, "hp", 0 - amount);
}
a non-void function must return a value on every reachable path

More mistakes the check catches

Each row quotes part of a message and says what to write instead.

MessageWhat to write instead
block comments are not allowedUse // line comments.
switch is not allowedUse if and else.
break is not allowedChange the loop, or wrap the rest of its body in an if.
continue is not allowedWrap the rest of the loop body in an if.
classic for loops are not allowedUse for (const item of list).
loose equality is not allowedUse ===.
loose inequality is not allowedUse !==.
exponentiation (**) is not allowedUse math.pow.
increment (++) is not allowedWrite count = count + 1.
compound assignment is not allowedWrite count = count + value.
typeof is not allowedCompare with === against a literal.
class is not allowedUse plain functions.
arrow functions are not allowedWrite a helper function at the top of the file.
async is not allowedRemove async. Rules run in one step.
template literals are not allowedUse string literals.
spread/rest (...) is not allowedWrite the items out.
optional chaining (?.) is not allowedCompare with null first.
nullish coalescing (??) is not allowedUse a ternary that compares with null.
throw is not allowedLeave the rule without writing.
try/catch is not allowedRemove try and catch.
new is not allowedWrite literals instead.
this is not allowedUse the self parameter.
method calls are not allowedCall only host functions and helpers of the same file.
default parameter values (=) are not allowedGive every parameter a value where the rule is used.
assignment targets must be local variablesBuild a new value instead of changing an object.
functions but only the actionRemove export from helper functions.
constructors must be pure and cannot read or write host stateRead and write in actions and ledger functions.
is not available untilA ledger function cannot write. Use an action.
effectful action helpers are not supportedLet the helper return a value, and write it in the action.

Host calls

Rules reach Fabled through host calls. You write them as they are, such as ledger.add(...). You never import them.

In these signatures, HostValue is any value a host call can give back, so cast it with as before you use it. AbiValue is a number, text, boolean or null, or a list or record of those. An optional sticky parameter is for Fabled's own use, so leave it out.

element.var(ref: ElementRef, name: HostValue, sticky?: AbiValue): HostValue;
ledger.set(ref: ElementRef, key: string, value: AbiValue): void;
ledger.add(ref: ElementRef, key: string, value: number): void;
math.floor(x: number): number;
math.ceil(x: number): number;
math.round(x: number): number;
math.max(...values: number[]): number;
math.min(...values: number[]): number;
math.abs(x: number): number;
math.pow(base: number, exponent: number): number;
math.sqrt(x: number): number;
list.add(ref: ElementRef, key: string, item: ContentRef, offer: ContentOffer): void;
list.remove(ref: ElementRef, key: string, item: ContentRef): void;
list.count(ref: ElementRef, key: string): number;
offer.where(where: ContentWhere, opts?: { maxItems?: number; flag?: { by: string; map: Record<string, string> } }): ContentOffer;
CallWhereWhat it does
element.varActions, ledger functionsReads a stored value of an Element. Cast the result with as number, as string or as boolean.
ledger.setActionsSets a stored field to a value.
ledger.addActionsAdds a number to a stored field. Add a negative number to subtract.
math.floorAnywhereRounds down.
math.ceilAnywhereRounds up.
math.roundAnywhereRounds to the nearest whole number.
math.maxAnywhereReturns the largest of its numbers.
math.minAnywhereReturns the smallest of its numbers.
math.absAnywhereReturns the size of a number without its sign.
math.powAnywhereRaises a base to an exponent.
math.sqrtAnywhereReturns the square root.
list.addActionsAdds the picked entry to a list. The offer decides what may be added. Refused with NOT_OFFERED, ALREADY_IN_LIST or LIST_FULL.
list.removeActionsRemoves the picked entry from a list. Refused with NOT_IN_LIST.
list.countActionsReturns how many entries a list holds. Use it in a guard before list.add.
offer.whereActionsBuilds the offer for list.add: the entries whose fields match conditions you write as literals.

A list call names a list of the Framework by a literal key, and the entry is a ContentRef, an input that holds the entry the player picks. An offer takes literal conditions only, such as offer.where({ weight: { lte: 3 } }). Each field takes eq, ne, gte, lte, in or has. An offer cannot read the Element. To depend on a value of the character, test it with if before list.add.

rules/pack-trinket.ts
export function pack_trinket(self: ElementRef, trinket: ContentRef): void {
  if (list.count(self, "pack") < 2) {
    list.add(self, "pack", trinket, offer.where({ weight: { lte: 3 } }));
  }
}
  1. Line 2 Stop when the pack already holds two entries.
  2. Line 3 The offer allows only entries that weigh 3 or less.
rules/unpack-trinket.ts
export function unpack_trinket(self: ElementRef, trinket: ContentRef): void {
  list.remove(self, "pack", trinket);
}
  1. Line 2 Remove the picked entry. A list action that only removes needs one accepted step.

Field names are written out

In an action, the name of a field must be a string written in the code, such as "hp". It cannot come from a value. A rule like this fails:

export function hurt(self: ElementRef, skill: string): void {
  const value: number = element.var(self, skill) as number;
  ledger.set(self, "hp", value);
}
Action accesses require literal keys on self.

So, without a field list, one action cannot check any skill by name. Write one action for each skill, and let each read its own field. The Roll against a skill recipe shows a short action to copy. The exception is an input that lists its fields. Give the action input a "fields" list, such as ["lore", "library_use"], and the action may pass that input as the name, as in ledger.add(self, skill, amount). The check treats the action as touching every listed field, and the server accepts a write only to the field the player picked.

This action raises whichever skill the player picks:

schemas/keeper.json
    {
      "id": "improve",
      "name": "Improve a skill",
      "source": "rules/improve.ts",
      "inputs": {
        "skill": { "label": "Skill", "fields": ["lore", "library_use"] }
      }
    }
  1. Line 25 The input skill picks one of these fields by name. A list needs at least two.
rules/improve.ts
export function improve(self: ElementRef, skill: string): void {
  ledger.add(self, skill, 1);
}
  1. Line 2 skill holds the name of the field the player picked.

Every name in fields must be a stored field of the Framework that an action may write. A field that a ledger function derives, or a modifier, is refused. If a field has a matching schema input with a label, the dialog shows that label; otherwise it shows the field id, such as library_use. For example, inputs.library_use.label can name the choice Library use. The selected value, scenario parameters and log placeholders such as {skill} still use the field id. Any native string choice list whose values all name writable scalar fields uses the same labels.

fields "skill_total" is not a writable state field

Only declared fields

An action can write only the fields that the constructor returns. The check names the field it cannot find.

Action "keeper.heal" calls ledger.set(self, "mana", ...), and "mana" is not declared on this schema. Declared scalar fields: hp, max_hp, name.

Next