Model reference
Every key of a Tabletwin game model, layer by layer. A model is one YAML file. The engine plays it (in the player and in the validator's test games); the validator checks it. This page describes what both actually do.
Related pages:
- expressions.md: the expression language used in every
_xfield, and which names are in scope where. - board-geometry.md:
boardsandfunctionsin depth. - engine.md: how a model is run, step by step.
- guide.md: how to turn a rulebook into a model.
- cheatsheet.md: the essentials of this page on one page, with complete patterns.
- tactics.md: tactic files (players as a separate layer).
- schema.json: JSON Schema for editors; the validator also uses it to report unknown keys. Where it disagrees with this page, this page wins.
In the tables, E means the engine uses the field, V means it is for people and the viewer only. A V field never changes how the game plays.
1. Conventions
Text and expression (_x)
Most fields that carry rules come in two forms:
| Form | Example | Meaning |
|---|---|---|
field with text | pre: "has enough gold" | For people and the viewer. The engine cannot run it. |
field_x with an expression | pre_x: "gold[actor] >= 3" | What the engine runs. |
field with a number, list, bool or dict | amount: -1, die: [1, 2, 3] | A literal value, used as is (only for the fields listed below). |
If both are present, _x wins. An _x field may also hold a YAML number or boolean
(if_x: true); it is turned into text and evaluated.
Fields that accept a literal (a non-text value without _x): value, amount, what,
at, n, die, options, min, max, items, until (of repeat), target, of.
For these, a text value without _x is an error at run time:
field 'what' is text without what_x. The exception is value in set and let: there a
text is a literal string (value: open stores 'open'; value: "x + 1" stores the text
'x + 1').
So to place the string 'gold' in a zone, write what_x: "'gold'", not what: gold.
Fields that only work as _x. What the engine does when only the text form is present:
Text field without _x | Engine |
|---|---|
pre (action), if (trigger), cond (if op), when (branch case), first (priority protocol), of (exec), change (modifier, modify), p (outcome) | Error NotExecutable when reached |
over, until (loop) | Ignored: the loop runs its children once per round, and never stops by itself |
actor (step), controller (trigger) | Ignored: no actor / the event's actor decides |
if (end condition) | The condition is skipped |
if (modifier) | Ignored: the modifier always applies |
filter (search) | Ignored: every item is a candidate |
who (end condition) | Ignored, so who_x is missing: error when a competitive win/loss fires, and at the first end check for eliminate |
The validator counts most of these places as gaps (its list "What the engine still misses", see §20).
Paths
| Kind | Syntax | Example |
|---|---|---|
Variable path (var in set, add) | name or name[expr] | score[current], pieces['e4'] |
Zone path (from, to, zone) | name or name[expr] | deck, hand[actor] |
The part in [ ] is an expression, evaluated with the local names of the op. A zone with
owner: player must be indexed (hand[1]); without an index the engine raises
zone 'hand' belongs to a player; write hand[player].
Players
Players are the integers 0 … num_players − 1. In expressions, players is that list and
n_players its length.
YAML 1.1 pitfalls
Models are read as YAML 1.1:
yes,no,on,off(lower, title or upper case) are booleans, liketrueandfalse. A keyon:becomestrueand is rejected (key true is not text; put it in quotes). That is why triggers useevent:, noton:, and branches useswitch:.options: [yes, no]gives[True, False].010is octal (8),1:30is sexagesimal (90). Quote such values.- Dates (
2024-01-01) stay text. - Keys must be text;
<<merge keys work.
2. Top-level keys
| Key | Type | Meaning | |
|---|---|---|---|
game | text | V | Short id (lower case, hyphens) |
label | text | V | Display name |
num_players | integer | E | Number of players (default 2) |
mode | competitive / cooperative | E V | How end conditions pay out (default competitive; anything else is an error) |
players | text | V | E.g. "2–4 players" |
summary | text | V | Two or three sentences about the game |
demonstrates, scope, description | text | V | What the model shows and what it does (not) cover. This scope is unrelated to scope in end |
utility | {min, max} | V | Range of a player's return. The engine does not use it |
constants | dict | E | §3 |
boards | dict | E | §4 |
functions | dict | E | §5 |
state | dict | E | §6 |
flow | node | E | The main flow. Required by the engine (§9) |
flows | dict | E | Named subflows |
actions | dict | E | §10 |
triggers | list | E | §14 |
modifiers | list | E | §15 |
priority | dict | E | §13 |
outcomes | dict | E | §17 |
agents | dict | E | §18 |
content | dict | E | §16 |
code | dict | E | §21 |
open_questions | list | V | §22 |
end | list | E | §19 |
The JSON Schema also requires game, label, actions and end, and allows no other top-level
keys.
3. constants
{name: value}. Fixed numbers, lists and tables. Every expression sees them under their name.
Use upper case. Numbers you may want to change later (a target score, a hand size) belong here:
the site always runs a model with the values written in it, so you change them in one place.
constants:
GOAL: 20
CARDS: [{id: ace, value: 1}, {id: king, value: 13}]
Constants are model data: they are never changed during play. A dict from constants placed in
a zone with place stays read-only; use create to make an instance (§7).
4. boards
{name: board}. Fixed boards with geometry: square or hex cells, neighbours, holes, walls.
Expressions call board functions with the board name as first argument
(neighbours('map', cell)). Full description in board-geometry.md.
boards:
board: {kind: square, width: 8, height: 8, names: letters, neighbours: 8}
5. functions
{name: function}. Your own pure functions in the expression language.
| Field | Meaning | |
|---|---|---|
params | E | List of parameter names (^[A-Za-z][A-Za-z0-9_]*$). name? is optional (default None). Call by position or by name |
let | E | {name: expression}, evaluated in order; each sees the parameters and earlier lets |
x | E | The result (required) |
text, note | V | Explanation |
functions:
other:
params: [p]
x: "1 - p"
A function sees its parameters, its lets and the global part of the scope (constants, state,
derived values, board functions, other functions; recursion up to 100 calls deep). It does not
see the caller's local names (actor, self, bound names): pass them as arguments. Its name
must not clash with a constant, variable, zone, derived value, builtin, board function or
reserved name (modified content players n_players eliminated results None). See
board-geometry.md.
6. state
state.entity_types
{name: {attrs: {name: type}, extends?: name, note?}}. V only: describes the kinds of things,
and holds of a zone may name one. Things in zones are plain values (text, number or dict).
state.zones
{name: zone}. Places where things lie. In expressions a zone is a list; a zone with
owner: player is a list of lists (hand[1]). Index 0 is the top.
| Field | Default | Meaning | |
|---|---|---|---|
owner | E V | global | global: one list. player: one list per player, path name[i]. team, entity: descriptive (treated as global) |
visibility | E V | public | Who sees what is drawn into it: public everyone, owner only the owning player, hidden nobody |
shuffled | E V | false | true: drawing from it is random (a chance node); order is unknown except a known top and bottom (§8) |
persist | E V | – | campaign: kept by reset. Any other value is a validator error |
drawn_from | E | – | For a hidden zone: the shuffled zone its cards come from. A player that looks ahead redistributes its cards together with the unknown part of that zone, so it does not peek. See tactics.md |
init_x | E | empty | Initial content (a list; for owner: player a list of lists). Dicts become instances (§7) |
kind | V | – | grid, hexgrid, graph, plane, stack, set, bag, slot, track |
holds, size, label, note | V | – | Descriptive |
state:
zones:
deck: {kind: stack, visibility: hidden, shuffled: true, init_x: "list(CARDS)"}
hand: {kind: set, owner: player, visibility: owner}
discard: {kind: stack}
init_x of a zone sees the constants, content, players, n_players, board and own functions,
and all variables. It does not see other zones.
state.vars
{name: var}.
| Field | Default | Meaning | |
|---|---|---|---|
scope | E V | global | player: one value per player (a list; path name[i]). team, entity: descriptive |
init | E | None | Literal initial value (null is None). With scope: player and a non-list value, every player gets a copy |
init_x | E | – | Initial value as an expression; sees constants, content, players, n_players, board and own functions, and the variables declared above it |
persist | E V | – | campaign: kept by reset |
type | V | – | int, real, bool, enum, ref, set, list, dict, str (descriptive) |
range, label, note | V | – | Descriptive |
state:
vars:
score: {scope: player, type: int, init: 0}
current: {type: ref, init: 0}
pool_size: {init_x: "2 * n_players"}
state.derived
{name: "expression"} or {name: {text, x}}. Named values, computed each time an expression
uses them (directly or through another derived value). A derived value may use derived values
declared above it. It is computed in the scope of the expression that uses it; write it so
that it needs only the global state.
state:
derived:
total: "sum(score)"
best: {text: "highest score so far", x: "max(score)"}
leaders: "[p for p in players if score[p] == best]"
7. Instances
A dict in a zone is an instance: a thing with its own state that can change during play
(damage, exhausted, tokens). Instances come from create with a dict, and from a zone's
init_x that yields dicts.
- Each instance gets a unique
uid(1, 2, 3, … in order of creation). - If the source dict is model data (from
constantsorcontent), the instance copies its simple fields (numbers, text, booleans,None) and refers to the source under the keyorigin(inst['origin']['effect']). Nested fields such aseffectortriggersare not copied. A dict built by an expression is copied whole. with_xoncreateadds initial state:with_x: "{'damage': 0}".- Read:
c['damage'],c.get('exhausted'),c['origin']['effect']. - Write:
{op: add, of_x: c, attr: damage, amount: 2},{op: set, of_x: c, attr: exhausted, value: true}. Changing model data itself is an error:'X' is model data; make an instance with create first. - In options, chance outcomes and observations an instance is named by its
label, elseid, elseuid. Two instances with the same label are indistinguishable to a player. execneeds a dict witheffect; for an instance useof_x: "c['origin']".
- {op: create, what_x: "content['units']['archer']", to: "army[actor]", with_x: "{'damage': 0}", bind: u}
- {op: add, of_x: u, attr: damage, amount: 1}
8. Stack order
Index 0 of a zone is the top: draw and look take from there. Inserting without position
appends at the end (the bottom).
For a zone with shuffled: true the order is unknown, except for a known top and a known
bottom:
- known on top: after
look, or afterplace/create/move/drawwithposition: top; - known at the bottom: after
place/create/move/drawwithposition: bottom; - the rest is unordered; a chance node decides which card it is when it is drawn or looked at;
- something inserted without
position(orposition: random) goes among the unknown cards; shufflemakes all of it unknown again.
Drawing takes the known top first, then (chance node, uniform) one of the unknown cards, and
only when those are gone the known bottom. For a zone without shuffled, shuffle does nothing
and draw always takes index 0.
9. flow and flows
flow is the main flow: one node, usually a sequence or loop. flows is {name: node}
with named subflows (used by subflow nodes and the run op).
| Field | On | Meaning | |
|---|---|---|---|
id | all | E V | Unique in the whole model (validator error on duplicates) |
kind | all | E V | sequence, loop, branch, step, subflow |
label | all | V | Display name (else id) |
children | sequence, loop | E V | List of nodes, run in order |
over_x | loop | E | List (or other iterable) of values to run the children for, evaluated at the start of each round. Usually players |
as | loop | E | Variable set to each value of over_x before the children run. Declare it in state.vars (a validator error otherwise): the engine creates an undeclared one only when the loop first sets it, so it does not exist before that, and reset leaves it alone |
until_x | loop | E | Stop condition, tested before each round. Without it the loop repeats round after round (with over_x: all values again, from the first) and never stops by itself; the game ends through end |
once | loop | E V | true: exactly one round (each value of over_x once) |
over, until | loop | V | Text for people |
order | loop | V | fixed, clockwise_from_first, initiative, simultaneous, deck_order, players_choose (descriptive; other values are a validator error) |
cases | branch | E V | [{when, when_x, label, note, children}]. The first case whose when_x is true runs; none true: nothing. A case without when_x reached before a match is an error |
switch | branch | V | What the branch looks at (text) |
ref | subflow | E V | Name in flows |
do | step | E V | Action name, or a list of action names among which the actor chooses |
actor_x | step | E | Who acts (player index). Needed if more than one action is legal, and for any choose in the action without its own actor_x |
actor | step | V | Text for people |
secret | step | E | Only the actor sees which action was chosen |
visible_to_x | step | E | Who sees the choice (list of player indexes) |
window | all | E V | A priority window opened after the node: a name from priority.windows, or true (all speeds) |
end_event | all | E V | Event name (or list) emitted when the node is done, after its window |
note | all | V | Explanation |
What each kind does:
- sequence: children in order.
- loop: before each round, if
until_xis true the loop ends. Withover_x, the children run once per value (withasset); without it, once per round. More than 10,000 rounds is an error (loop does not end). Eliminated players are not skipped: filter them yourself (over_x: "[p for p in players if p not in eliminated]"). - branch: the first case with a true
when_x. Usewhen_x: "True"for a default case. - subflow: runs
flows[ref].results[ref]is cleared when it starts; end conditions withscope: refapply while it runs (§19). - step: (1) evaluate
actor_x(else the actor isNone); (2) legal actions are those indowhosepre_xis true, withactorin scope; (3) none legal: the step is skipped; (4) one legal: it runs without a decision; (5) several: a decision for the actor (an error withoutactor_x); (6) the action'scost, then itseffect, with the same local names; (7) end conditions are checked.
After a node's own work comes its window, then its end_events (each followed by an end
check). On a loop both happen once, after the whole loop.
flow:
id: game
kind: loop
over_x: players
as: current
children:
- {id: turn, kind: step, actor_x: current, do: [roll, stop]}
Flow expressions (over_x, until_x, when_x, actor_x, visible_to_x) see only the global
part of the scope: no actor and no local names. Use the loop variable from as.
10. actions
{name: action}.
| Field | Meaning | |
|---|---|---|
pre_x | E | When the action is legal (default: always). Sees actor (and self for abilities; window and stack in a window) |
cost | E | Ops run first when the action is played. With lifo_stack they run immediately, while effect waits on the stack. Same local names as pre_x; in a window that includes stack as it is before this action is put on it |
effect | E | List of ops |
speed | E V | Makes the action playable in priority windows whose allows contains this speed (§13). Actions with a speed are usually not in any step |
pre, actor, label, note | V | Descriptive |
actions:
stop:
pre: the pot is not empty
pre_x: "pot > 0"
effect:
- {op: add, var: "score[current]", amount_x: pot}
- {op: set, var: pot, value: 0}
In pre_x, cost and effect, actor is the player who acts: the step's actor_x, in a
window the player who has priority, and when an effect resolves from the stack the player who
played it. Names bound in cost (by choose, let, …) are still there in effect, also when the
effect resolves later from the stack.
11. Ops
An effect is a list of ops, run top to bottom. Each op is a dict with op and its fields.
Local names: bind in choose, roll, let, look, search, create adds a local name for
the rest of the effect. if and repeat share the local names with the surrounding effect;
for_each, emit (impact and triggers), exec and sample work on a copy, so names bound
inside do not leak out.
| Op | Fields | Summary |
|---|---|---|
set | var or of_x+attr; value/value_x | Set a variable or an instance attribute |
add | var or of_x+attr; amount/amount_x | Add to a variable or attribute |
let | bind, value/value_x | Bind a local name |
place | what/what_x, to, at/at_x, position | Put a value in a zone |
create | what/what_x, to, n/n_x, with_x, position, bind | Add new instances |
move | what/what_x, from, to, position | Move items between zones |
destroy | what/what_x, from | Remove an item from play |
draw | from, to, n/n_x, visible_to_x, position | Take the top card(s) |
shuffle | zone | Make a shuffled zone's order unknown again |
look | from, n/n_x, bind, actor_x, visible_to_x | Look at the top card(s) |
search | from, bind, filter_x, as, actor_x, min/max, visible_to_x | Look through a zone and pick |
reveal | what/what_x, visible_to_x | Show a value |
roll | die/die_x, bind, visible_to_x | Uniform chance node |
choose | options/options_x, bind, actor_x, secret, visible_to_x, min/max | A decision |
if | cond_x, then, else | Conditional |
for_each | items/items_x, as, body | Loop over values |
repeat | until/until_x, body | Loop until a condition |
emit | event, impact | Fire an event (triggers) |
sample | model | Draw from an outcome model |
exec | of_x, content | Run a content item's effect |
run | flow | Run a named flow inside the effect |
window | name | Open a priority window |
cancel | target/target_x | Cancel a stack item |
modify | stat, change_x, layer, if_x, until | Add a temporary modifier |
reset | scope | Campaign: start a new scenario |
call | code | Escape hatch: code outside the model (not executable) |
say | actor, channel, constraint, bind, agent | Free speech (not executable yet) |
Any other op is a validator error and NotExecutable at run time. Every op may carry a note
(V).
set
| Field | Default | Meaning |
|---|---|---|
var | – | Variable path. Required unless attr is given |
of / of_x | – | With attr: the instance to change |
attr | – | Attribute name (literal) |
value / value_x | – | New value. A text value is a literal string |
- {op: set, var: phase, value: combat} # the string 'combat'
- {op: set, var: "score[actor]", value_x: "score[actor] * 2"}
- {op: set, of_x: target, attr: exhausted, value: true}
add
| Field | Default | Meaning |
|---|---|---|
var | – | Variable path. Required unless attr is given |
of / of_x, attr | – | Add to an instance attribute; a missing (or falsy) attribute counts as 0 |
amount / amount_x | – | Added with + (negative subtracts; on a list variable + concatenates) |
- {op: add, var: "gold[actor]", amount: -3}
- {op: add, of_x: unit, attr: damage, amount_x: "hit['power']"}
let
| Field | Meaning |
|---|---|
bind | Local name (required) |
value / value_x | Value. A text value is a literal string |
- {op: let, bind: target, value_x: "hand[actor][0]"}
place
| Field | Default | Meaning |
|---|---|---|
what / what_x | – | The value |
to | – | Zone path |
at / at_x | – | Index: replaces the element at that index (negative counts from the end) |
position | – | Without at: top, bottom, random or none (§8). Any other value is an error |
place does not make an instance; use create for things with their own state.
- {op: place, what_x: "'X'", to: grid, at_x: square}
create
| Field | Default | Meaning |
|---|---|---|
what / what_x | – | The thing; a dict becomes a new instance (§7) |
to | – | Zone path |
n / n_x | 1 | Number of copies |
with_x | – | Dict of extra initial state, merged into each instance |
position | – | top, bottom, random or none |
bind | – | Local name for the (last) instance created |
- {op: create, what_x: "content['tokens']['wound']", to: "wounds[actor]", n: 2}
move
| Field | Default | Meaning |
|---|---|---|
what / what_x | – | An item or a list of items |
from, to | – | Zone paths |
position | – | top, bottom, random or none |
For each item the first equal element (deep equality) in from is removed and inserted into
to. An item that is not in from is an error: move: 'X' is not in hand[actor] (the path as
written).
- {op: move, what_x: card, from: "hand[actor]", to: discard}
destroy
| Field | Default | Meaning |
|---|---|---|
what / what_x | – | The item |
from | – | Zone path. Without it: the first zone (in declaration order) that holds the item |
Not found is an error. Adds the event destroy:<label>.
draw
| Field | Default | Meaning |
|---|---|---|
from, to | – | Zone paths |
n / n_x | 1 | Number of cards; stops early when from is empty |
visible_to_x | destination | Who sees the drawn card. Default: from the destination's visibility (public everyone, owner its owner, hidden nobody) |
position | – | Where it goes in to |
From a shuffled zone without a known top and with more than one unknown card, each draw is a
chance node (uniform over the unknown cards; §8). Otherwise the top card is
taken.
- {op: draw, from: deck, to: "hand[actor]", n: 2}
shuffle
| Field | Meaning |
|---|---|
zone | Zone path. Without it the op does nothing |
Forgets the known top and bottom of a shuffled zone. On a zone without shuffled it does
nothing.
look
| Field | Default | Meaning |
|---|---|---|
from | – | Zone path |
n / n_x | 1 | How many cards from the top |
bind | – | Local name for the list of cards seen |
actor_x | the local actor | Who looks |
visible_to_x | [actor] | Who sees them (everyone if there is no actor) |
On a shuffled zone, chance nodes fix the unknown cards among the top n; afterwards they are
the known top.
search
| Field | Default | Meaning |
|---|---|---|
from | – | Zone path |
bind | – | Local name for the chosen item (required) |
filter_x | all | Which items may be chosen; the item is named it (or as) |
as | it | Name of the item in filter_x |
actor_x | the local actor | Who searches |
min, max (and _x) | – | Choose a subset, as in choose; bind gets a list |
visible_to_x | [actor] | Who sees the choice |
The searcher sees the whole zone (an observation for the actor only). Then a choose among the
matching items follows (one match: no decision). No match: bind is None, or [] with
min/max. search does not move or shuffle anything; add move and shuffle yourself.
- {op: search, from: deck, filter_x: "it['type'] == 'ally'", bind: found}
- {op: if, cond_x: "found is not None", then: [{op: move, what_x: found, from: deck, to: "hand[actor]"}]}
- {op: shuffle, zone: deck}
reveal
| Field | Default | Meaning |
|---|---|---|
what / what_x | – | The value shown |
visible_to_x | everyone | Who sees it |
Adds an observation. The zone's visibility does not change.
roll
| Field | Default | Meaning |
|---|---|---|
die / die_x | – | List of faces (numbers, text or dicts) |
bind | – | Local name for the face rolled |
visible_to_x | everyone | Who sees the result |
A chance node; each face is equally likely. A random card from a hand: die_x: "hand[p]".
- {op: roll, die: [1, 2, 3, 4, 5, 6], bind: pips}
choose
| Field | Default | Meaning |
|---|---|---|
options / options_x | – | The options (a list or other iterable) |
bind | – | Local name for the chosen option (required) |
actor_x | the local actor | Who chooses |
secret | false | Only the chooser sees the choice |
visible_to_x | everyone | Who sees the choice |
min / min_x, max / max_x | – | Subset choice (see below) |
agent | – | Name in agents (validator checks it exists; the engine ignores it) |
One option: no decision (the player lists it under "automatic"). No options: an error. More than
one option and no actor: NotExecutable.
Subset. With any of min, max, min_x, max_x, the chooser picks elements one at a time,
in the order of options: each time from the elements after the last one picked (only those
that still leave enough elements to reach min), plus the option done once at least min are
chosen. Defaults: min 0, max the number of options. It stops at max, at done, or when no
candidates are left. bind gets the list. No options and min 0 gives []; fewer than min
available is an error. Each element is its own decision, at place <place> · 1, · 2, …
- {op: choose, options_x: "hand[actor]", bind: discards, min: 0, max: 2}
if
| Field | Meaning |
|---|---|
cond_x | Condition (required; cond alone is an error) |
then, else | Lists of ops; else optional |
for_each
| Field | Default | Meaning |
|---|---|---|
items / items_x | – | Values to loop over, evaluated once at the start |
as | it | Local name of the current value |
body | – | Ops, run once per value with a copy of the local names |
- op: for_each
items_x: "[p for p in players if p != actor]"
as: p
body: [{op: add, var: "gold[p]", amount: 1}]
If items_x is a zone itself (hand[actor]) and the body moves things out of that zone, the
loop skips items. Loop over a copy: items_x: "list(hand[actor])".
repeat
| Field | Meaning |
|---|---|
until / until_x | Tested before each pass; the body runs while it is false |
body | Ops (same local names as the surrounding effect) |
More than 10,000 passes is an error (repeat does not end).
emit
| Field | Meaning |
|---|---|
event | Event name (literal text; there is no event_x) |
impact | Optional ops: what the event itself does |
Order: triggers with timing when/before; then instead triggers (the first one that fires
replaces the impact); then the impact (if not replaced); then after triggers. Afterwards,
temporary modifiers with until equal to this event expire. Triggers see the local names at the
emit plus event. A chain of more than 50 nested emits is an error. See
§14.
- op: emit
event: attack
impact: [{op: add, of_x: target, attr: damage, amount_x: power}]
sample
| Field | Meaning |
|---|---|
model | Name in outcomes |
inputs | Text for people (the engine passes the local names instead) |
Evaluates the model's derived_x and p_x, makes a chance node over its outcomes, adds the
event <model>:<outcome id> and runs that outcome's effect. See §17.
exec
| Field | Meaning |
|---|---|
of_x | The content item (a dict with effect). Required |
content | Catalogue path for the validator (e.g. cards.*); the engine ignores it |
Runs the item's effect with self set to the item, in place content <id>. Adds the event
exec:<id>. An item with status: missing or without effect is NotExecutable.
run
| Field | Meaning |
|---|---|
flow | Name in flows |
Runs the named flow inside this effect, with its own cursor. results[flow] is cleared first;
end conditions with scope: flow apply while it runs.
window
| Field | Meaning |
|---|---|
name | Name in priority.windows; without it all speeds are allowed |
Opens a priority window from inside an effect. The window sees the op's local names (and
first_x is evaluated with them). See §13.
cancel
| Field | Meaning |
|---|---|
target / target_x | Index in stack of the innermost open window (0 is the bottom; −1 the top). None does nothing; an index out of range is an IndexError |
Sets cancelled: true on that stack item when the op runs: in a cost at once, in an effect
(the usual place) only when that effect resolves. Until then the target still shows
cancelled: false, so two players can both target the same item. Cancelling an item that is
already cancelled is allowed and changes nothing. A cancelled item is removed without effect
when it would resolve (event cancelled:<action>; its cost was already paid). Outside an open
window: an error. Only useful with resolution: lifo_stack.
Fix the target when the counter is played: bind it in cost with let (cost ops see
stack), and cancel it in effect. Items below the counter do not move before it resolves, so
the index stays valid. Here the target is the topmost item of another player that is not yet
cancelled:
counter:
speed: fast
pre_x: "any(s['actor'] != actor and not s['cancelled'] for s in stack)"
cost:
- {op: let, bind: target, value_x: "max(i for i, s in enumerate(stack) if s['actor'] != actor and not s['cancelled'])"}
effect: [{op: cancel, target_x: target}]
A bare target: -1 in effect means "the item directly below this counter when it resolves"
(the counter itself is already off the stack), which may be your own item or one that is already
cancelled.
modify
| Field | Default | Meaning |
|---|---|---|
stat | – | Which quantity (required) |
change_x | – | New value; value is the value so far (required) |
layer | 0 | Order among modifiers |
if_x | – | Only active while true |
until | – | Event at which the modifier expires; without it, it lasts until reset |
Adds a temporary modifier. The local names at the time of modify (except stack) are kept and
visible in if_x and change_x; self (if any) is kept as its content item. See
§15.
- {op: modify, stat: attack, change_x: "value + 2 if who == actor else value", until: round_end}
reset
| Field | Default | Meaning |
|---|---|---|
scope | scenario | Label only (event reset:<scope>) |
Campaign: every variable and zone without persist: campaign gets its initial value again
(init_x is recomputed); eliminated and temporary modifiers are cleared. results is kept.
call
| Field | Meaning |
|---|---|
code | Name in code |
Escape hatch: marks a place where the work would be done by code outside the model
(code.<name>). A model cannot bring its own code, so a call always stops the engine with
NotExecutable (code 'x' → m:f is not implemented) and counts as a gap. Use it only to record
honestly what the ops cannot express yet; avoid it where the model can express the rule.
say
Fields actor, channel, constraint, bind, agent. Free speech for language games. Always
NotExecutable for now (say needs a language agent); the validator warns without agent.
12. Places, options and events
The engine names every decision and chance node by its place (where). Tactics match on it
(at:), and the player shows it for every question.
| Place | Where it comes from |
|---|---|
step <id> | Choice between the actions of a step |
action <name> | A choose, roll, draw, look or search in the action's effect (also for abilities) |
action <name> (cost) | The same in its cost |
<place> · <k> | The k-th element of a subset choice (action play · 1) |
window <name> | A priority decision; window None when opened with window: true or a window op without a name |
trigger <id> | The use/skip decision of an optional trigger, and ops in its do (id of the trigger, else of its content item) |
content <id> | Ops in an effect run by exec |
<place> · <model> | The chance node of a sample |
<place> · <event> | Ops in the impact of an emit |
end <node id> | The emit of an end_event (only seen in error messages) |
Option labels made by the engine: pass (priority window), done (end of a subset choice),
use / skip (optional trigger). Other options are labelled by their value: text as is, an
instance by label/id/uid, anything else in Python notation.
Events recorded by the engine (the player shows them after each question; the ones you do not emit yourself appear under "messages"):
| Event | When |
|---|---|
<event> | Every emit (including end_events) |
instead:<trigger id> | An instead trigger fired |
stack:<action>, resolves:<action>, cancelled:<action> | lifo_stack: put on the stack, resolved, removed as cancelled |
<model>:<outcome id> | A sample |
exec:<id> | An exec |
destroy:<label> | A destroy |
reset:<scope> | A reset |
eliminated:<player> | An eliminate end condition |
<scope>:<result> | A scoped end condition ended a subflow |
13. Priority windows
priority:
protocol:
first: the active player # V
first_x: "current" # E: who gets priority first
closes: all_pass_in_succession # E: or single_pass_each
resolution: immediate # E: immediate, nested or lifo_stack
after_action: next # E: next or same
order: clockwise_from_first # V
windows:
combat: {label: Combat, allows: [fast], note: ...}
| Field | Default | Meaning | |
|---|---|---|---|
protocol.first_x | E | the local actor, else player 0 | Who gets priority first, and again after each stack item resolves; evaluated once when the window opens. first without first_x is an error |
protocol.closes | E | all_pass_in_succession | all_pass_in_succession: closes when all non-eliminated players pass one after the other. single_pass_each: a player who passes is out; closes when all are out |
protocol.resolution | E | immediate | immediate: cost and effect at once. nested: the engine treats it like immediate (use trigger timing for reactions). lifo_stack: cost at once, effect on a stack |
protocol.after_action | E | next | Who has priority after a played action: the next player, or the same |
protocol.order, protocol.note | V | clockwise_from_first, any_order, active_then_clockwise | |
windows.<name>.allows | E | – | List of speeds playable in this window |
windows.<name>.label, note | V |
How a window runs:
- Candidates are all actions (including content abilities) with a
speedthat the window allows (window: trueor awindowop without a name: any speed). No candidates: the window closes at once. - Priority passes from player to player (index + 1, wrapping; eliminated players are skipped).
A player with legal candidates (
pre_xtrue) chooses one of them orpass; without legal candidates the player passes automatically. - In scope (in
pre_x,costandeffectof the candidates):actor(who has priority),window(the window name,Nonefortrue),stack(list of{action, actor, cancelled}, bottom first; empty unlesslifo_stack), plus the local names of awindowop.costsees the stack before the action is put on it. immediate/nested: cost, effect, then an end check.lifo_stack: cost runs, the action goes on the stack (eventstack:X). When all players pass with a non-empty stack, the top item resolves: if cancelled it is removed (eventcancelled:X, no effect, no end check); otherwise its effect runs (eventresolves:X) withactor= who played it, the names bound in its cost, andstackwithout the item; then an end check. Then priority returns to the playerfirst_xgave when the window opened (first_xis not evaluated again, even if the effect changed what it reads), and passing starts over (single_pass_each: everyone is back in). That player passes automatically if they have no legal candidate.- All pass with an empty stack: the window closes (no end check).
passis reserved: an action namedpasswith aspeedis a validator error.speedandallowsare free names; they only have to match.
14. triggers
A list of rules that react to emit events.
| Field | Default | Meaning | |
|---|---|---|---|
id | E V | – | Unique name; used in the place trigger <id> |
event | E V | – | Event name it reacts to (exact match) |
timing | E V | after | when or before (before the impact), instead (replaces the impact; only the first that fires), after |
optional | E V | false | The controller decides use or skip |
controller_x | E | the event's actor | Who decides on an optional trigger. Without it and without an actor: an error |
if_x | E | – | Condition, tested right before the trigger would fire (an earlier trigger can disable a later one) |
do | E V | – | Ops |
if, controller, source, label, note | V | source: where the rule comes from (card, rule, keyword) |
Within one timing phase: mandatory triggers first, then optional ones; each group in model
order, model triggers before content triggers. In scope: the local names at the emit, event,
and self for a content trigger. A trigger on an end_event has only event.
triggers:
- id: thorns
event: attack
timing: after
if_x: "target['id'] == 'bramble'"
do: [{op: add, of_x: attacker, attr: damage, amount: 1}]
15. Modifiers
A modifier changes a quantity (stat) wherever the rules ask for it with
modified('stat', base, name=value, ...).
Static modifiers live in the top-level modifiers list and on content items. Temporary ones are
added by the modify op.
| Field | Default | Meaning | |
|---|---|---|---|
stat | E V | – | Which quantity. Modifiers without stat are ignored |
change_x | E | – | New value; value is the value so far. Missing: an error when applied |
layer | E V | 0 | Lower layers first; within a layer the static ones (top-level list first, then content items), then temporary ones in order of creation |
if_x | E | – | Only active while true |
until | E | – | (temporary) expires at this event |
id, source, target, change, if, duration, note | V | duration: permanent, round, turn, while |
modified('stat', base, k=v, ...): the extra keyword arguments are local names in if_x and
change_x (e.g. who attacks). A content modifier also sees self, and applies only while its
item's active_x is true. Modifiers that refer to each other more than 20 deep are an error.
modifiers:
- {id: veteran, stat: attack, layer: 1, if_x: "who == 0", change_x: "value + 1"}
actions:
hit:
effect:
- {op: add, var: "hp[1]", amount_x: "-modified('attack', 2, who=actor)"}
The validator warns when a stat is never read with modified('stat', ...) (a literal string).
16. content
Content is the game's material as data: cards, dice, faces, skills. The structure is free:
{catalogue: {key: item}}, nested as deep as you like, lists allowed. Expressions see it as
content.
content:
cards:
bolt:
id: bolt
label: Lightning Bolt
status: verified
text: "Deal 3 damage."
effect: [{op: add, var: "hp[1 - actor]", amount: -3}]
An item is a dict with an id:
| Field | Meaning | |
|---|---|---|
id, label | E V | Identity and name |
status | V | verified, uncertain or missing. The validator warns for every item without one of these |
text | V | The card text in your own words. An item with text but no behaviour counts as a gap |
effect | E | Ops, run with exec (self is the item) |
active_x | E | When the item's triggers and modifiers apply; self is the item. Without it they always apply |
triggers, modifiers | E V | As in §14 and §15, bound to the item |
abilities | E V | Actions on the card itself (id unique across all actions and abilities, speed, pre_x, cost, effect). Playable in priority windows like any action with speed; self is the item. Without speed never playable (validator warning). The validator rejects them in a step's do |
keywords | E V | "Sentinel, Ranged" or a list. Their behaviour lives in the rules, which test them, e.g. 'Ranged' in c.get('keywords', ''). The validator warns for a keyword that no rule expression mentions in quotes |
faces | V | Die faces as "type number"; the validator checks that type is a key of some face_types dict in content |
source, cost, requires, note | V |
Triggers, modifiers and abilities belong to the item (the card kind), not to one instance.
With two copies of a card in play, its trigger still fires once per event; if_x picks the
right instance through the event's local names (e.g. card['origin']['id'] == self['id'], where
card is a name bound before the emit), and
active_x limits when the item counts at all (e.g. only while a copy is in play).
17. outcomes
{name: model}. An outcome model replaces physics or skill by a probability distribution.
| Field | Meaning | |
|---|---|---|
outcomes | E V | [{id, p, p_x, effect, note}]. The p_x must be ≥ 0 and add up to 1 (± 1e-6), else an error. The validator requires p (text) on each outcome as well |
derived_x | E | {name: expression}, evaluated in order before p_x; visible in p_x and effect |
skill | V | Depends on skill (skill marker instead of chance) |
inputs, params, derived, doc | V | Descriptive |
outcomes:
shot:
skill: true
derived_x: {q: "sigmoid(skill[actor] - DIFFICULTY)"}
outcomes:
- {id: hit, p: q, p_x: q, effect: [{op: add, var: "score[actor]", amount: 1}]}
- {id: miss, p: 1 - q, p_x: "1 - q"}
Used by {op: sample, model: shot}; the chance node's labels are the outcome ids.
18. agents
{name: agent}.
| Field | Meaning | |
|---|---|---|
policy_x | E | Returns the label of the option to choose. In scope: options (list of labels), decider (the player), and the global state. A result that is not one of the labels: a random choice |
kind | V | search, heuristic, llm, human |
roles, sees, prompt, note | V | Descriptive |
In the player, an agent with policy_x appears under Players as "heuristic
agents:
greedy: {kind: heuristic, policy_x: "'stop' if 'stop' in options and pot >= 10 else options[0]"}
19. end
A list of end conditions. At every check they are tested in list order, and the first one
that fires ends the game (or, with a scope, the subflow); later ones are not tested.
eliminate and continue never stop the scan.
| Field | Meaning | |
|---|---|---|
id | E V | Name; the reason reported when the game ends |
if_x | E | When the condition holds. Without if_x the condition is skipped |
result | E V | win, loss, draw, score, eliminate, continue |
who_x | E | win/loss in a competitive game: the winner resp. loser (one index; a list, tuple or set is an ExprError when the condition fires, and a validator error when who_x is obviously one, such as a list literal or comprehension). eliminate: an index or a list |
returns_x | E | score: list with one number per player |
scope | E V | A name in flows: applies only while that subflow runs (§9) |
if, who, note | V | Text |
check | V | immediate, end_of_turn, end_of_round, end_of_scenario (descriptive) |
When: after each step's action (a skipped step has no check), after each action played (or
resolved) in a window, and after each end_event. Not halfway through an effect, not after a cost, not after a cancelled
item, not when a window closes, not at the start or end of a loop round.
What:
- A condition with
scopeis skipped unless that subflow is running. If it holds and the result iswin,lossordraw,results[scope]is set to the result (event<scope>:<result>), the subflow ends and the calling flow continues. Ascopeequal to the main flow'sidmeans the whole game. eliminate: the players fromwho_xare added toeliminated(eventeliminated:<p>); play goes on.continue: nothing.- The first other condition that holds ends the game. A
score(oreliminate,continue) condition with ascopeis only tested while that subflow runs and then acts as without scope: a scopedscoreends the whole game.
Returns per player:
| Result | Competitive | Cooperative |
|---|---|---|
win | +1 for who_x, −1 for everyone else | +1 for everyone |
loss | −1 for who_x, +1 for everyone else | −1 for everyone |
draw | 0 for every player | 0 for every player |
score | returns_x | returns_x |
Several winners (or losers) in a competitive game cannot be expressed with win/loss: who_x
must be one player index. Use score, e.g.
returns_x: "[1 if p in winners else -1 for p in players]".
If the main flow finishes without an end condition, the game ends with 0 for everyone and the
reason flow finished without an end condition.
end:
- {id: won, if_x: "score[current] >= GOAL", result: win, who_x: current}
- {id: out, if_x: "any(hp[p] <= 0 for p in players)", result: eliminate, who_x: "[p for p in players if hp[p] <= 0]"}
- {id: tally, if_x: "rounds_played >= ROUNDS", result: score, returns_x: "[float(s) for s in score]"}
20. Validator
The validator page shows the errors and warnings below, the executability with a few figures, the gaps ("What the engine still misses", per layer), the flow as a text tree, and the result of 20 random test games. See engine.md for what each part means.
Errors (must fix): unknown mode; persist other than campaign; duplicate flow id;
unknown kind or order; branch without cases; subflow to a missing flow; loop as
variable not declared in state.vars; window not in
priority.windows; a step's action missing from actions; unknown op; undeclared zone in
from/to/zone; undeclared code, flow (run), outcome model (sample) or agent
(choose/say); exec content path that does not exist; end_event that is not a name or list of
names; expression syntax errors; invalid board; function name clash, missing x, bad params
or let; unknown trigger timing; unknown resolution or closes; a speed action named
pass; end scope that is not a named flow; competitive win/loss whose who_x obviously
gives several players (a list, tuple or set literal, a comprehension, or list(), sorted(),
set(), tuple()); outcome model without outcomes or an outcome
without p; ability without id or with a name that already exists; undeclared face type;
open question without question.
Warnings: drawn_from to a zone that is not shuffled, or on a public zone; content item
without a valid status; ability without speed; keyword not used by any rule; board or
function never used; say without agent; exec without content or of_x; optional
trigger without controller_x; speed not allowed by any window; a fast action that is always
playable in a window that is opened (below); modifier stat (static or via
modify) never read with modified(); trigger on an event that is never emitted; action not
used by any step (unless it has note or speed); flow never called; open question with a
status other than open, assumed, answered; a key the
JSON Schema does not allow
(unknown key 'x' at actions.y: a typo, or a field the engine ignores).
Always playable (action X: always playable in window Y; random players may keep the window open forever): the action has a speed that window Y allows, Y is opened somewhere (a
node's window or a window op; window: true allows every speed), and the action has no
pre/pre_x, no cost, and nothing in its effect that could end the game: no set/add of
a variable, and no zone op on a zone, that an end condition's if_x reads (also through derived
values and functions), no emit that a trigger listens to, no attribute write, and no run,
exec, sample, call, reset or say. Such an action can be played again and again: random
players rarely all pass in succession, and an agent that never passes hits window does not close. Give it a pre_x (a resource, once per window) or a cost.
Unknown keys: the validator walks the model alongside the JSON schema and reports every
key where the schema allows no other keys, with its path. content has a free structure and is
not checked; constants and other free dicts neither.
The validator does not check that a var path names a declared variable, nor that
expressions only use known names; those fail at run time, which the test games usually show.
Figures:
| Figure | Meaning |
|---|---|
executable | Share of engine-needed fields that have an _x or a literal (rules and content together) |
| rules | The same, for flow, actions, triggers, modifiers, priority, outcomes and end |
| content | Share of content items with behaviour (or text, or status: missing) that are fully executable, with counts; - without content |
| steps | Number of step nodes |
| ops | Number of ops in actions, triggers, outcomes and content |
| code | Share of call ops |
| open questions | Number of open_questions |
Markers of a step (shown in the diagram; from the ops of its actions,
including flows they run):
choice: several actions indo, or achoose,sayorsearch;chance:shuffle,roll,drawfrom a shuffled zone,lookat a shuffled zone,samplefrom a model withoutskill;skill:samplefrom a model withskill: true;hidden:choosewithsecret,move/drawto an unshuffled zone with visibilityhiddenorowner,search,look;window: awindowon the step, or awindowop in its actions (awindowon any other kind of node is not counted);trigger: anemitof an event some trigger listens to;language:say, orchoosewith anllmagent;persistent:set/addon a persistent variable,move/draw/place/createinto a persistent zone, orreset;code:call.
21. code
{name: {lang, ref, doc, kind}}. Describes code the rules would need but the ops cannot express:
doc says what it does, ref names it ("module:function"), lang its language. kind:
content (should really be in content) or algorithm. A call to it is never
executable; the share of call ops measures what the metamodel does not (yet) express.
22. open_questions
A list of gaps or ambiguities in the source: what the rulebook does not say, and what the model assumes. The engine ignores them; the diagram shows them as a gap report, and the profile counts them.
| Field | Meaning | |
|---|---|---|
id | V | Short unique name (required by the JSON Schema) |
question | V | The question, in one or two sentences. Required: missing is a validator error |
about | V | What it concerns: a path in the model (actions.attack, flow.round, end.won) or a short topic (setup) |
status | V | open (undecided; the default when missing), assumed (the model follows an assumption, given in answer), answered (the source or the designer settled it). Anything else is a validator warning |
answer | V | The assumption or the answer |
source | V | Where the question comes from (rulebook page, FAQ, forum thread) |
The JSON Schema allows no other fields. Write one entry per question; every assumed entry should
say in answer what the model does.
open_questions:
- {id: q1, about: actions.attack, question: "Can a unit attack twice?", status: assumed, answer: "No", source: "rulebook p. 6"}
- {id: q2, about: setup, question: "Who starts?", status: open}
23. Limits
| Limit | Value | Error |
|---|---|---|
| Rounds of one loop | 10,000 | loop does not end |
Passes of repeat | 10,000 | repeat does not end |
| Iterations of one window | 10,000 | window does not close |
Nested emits | 50 | trigger chain too deep |
| Own function call depth | 100 | more than 100 calls deep |
| Modifier nesting | 20 | modifiers for 'x' refer to each other in a cycle |
range() length | 10,000,000 | range too large |
24. Tools on the site
| Tool | Use it to |
|---|---|
| Validator | Check a pasted model (and optionally its view): errors, warnings, executability, gaps, the flow as a tree, 20 random test games. Then "Open in the player" |
| Player | Follow one game question by question, with random players, a heuristic from agents, or seats played by hand. "Game (seed)" replays the same game |
| Diagram | See the rules as a text tree and a track diagram ("Draw your own model") |
Everything runs in the browser; nothing is uploaded. Playing many games to measure win rates and comparing variants or tactics are not yet available on the site. See engine.md.