Guide: from rulebook to model
This guide takes you from a rulebook to a model that validates and plays. It is written for people and for AI agents: with the rulebook, this guide and the reference you should be able to write a working model without further help. All you need besides a text editor is the site: the validator checks a model, the player plays it and the diagram draws it. The expression language and the engine pages give the exact behaviour; this page gives the workflow, the decisions and the patterns. The quick reference has the essentials of all of them on one page.
The guide has four parts:
- What you build: one YAML file, two levels.
- Tutorial: a complete game, step by step, with what the validator and the player show.
- The workflow for any game, decision rules and patterns for everything Pig does not need.
- Pitfalls and the checklist.
What you build
A model is one YAML file that describes the rules of one game: what lies on the table (state),
the course of play (flow), what players can do (actions), reactions (triggers, windows,
modifiers), cards and dice as data (content) and how the game ends (end).
Every rule can be written at two levels:
| Level | You write | You get |
|---|---|---|
| Diagram | Structure and text: pre: "has enough gold" | The flow diagram and the rules overview on the site |
| Executable | Next to each text field an expression <field>_x: pre_x: "gold[actor] >= 3" | Test games in the validator, games in the player, tactics |
The text stays when you add the expression: people and the diagram read pre, the engine reads
pre_x. Always write the diagram level first and add the _x fields afterwards. A model may be
partly executable; the validator reports the percentage.
Two other kinds of files sit next to a model, and neither changes the rules:
| File | What | Page |
|---|---|---|
| A tactic | How someone plays: rules per decision place | tactics |
| A view | How the table looks in the player | views |
The smallest model
game: my-game # short id: lower case, hyphens
label: My game # display name
num_players: 2
mode: competitive # or cooperative
state:
vars:
done: {scope: global, type: bool, init: false}
flow:
id: game
kind: sequence
children:
- {id: move, kind: step, do: do_something}
actions:
do_something:
effect: [{op: set, var: done, value: true}]
end:
- {id: over, if: the move is done, if_x: done, result: draw}
The flow has one step; the step runs the action; the action sets a flag; after the step the engine
checks end, finds done true and ends the game in a draw. Paste it into the
validator: no errors, 100% executable, and 20 of 20 test games
end with over.
To get completion and checking in VS Code (YAML extension by Red Hat), put this on the first line of a model file:
# yaml-language-server: $schema=https://tabletwin.com/models/docs/schema.json
Tutorial: Pig from rulebook to executable model
We model Pig, a dice game, from its rules to a tested, executable model. Write the model in any text editor and check it by pasting it into the validator. The finished model of the same game, with the rulebook as comments, is pig.yaml; it differs from this one in small details, and it is in the player as "Pig".
The rulebook:
Pig (2 players). Players take turns. On your turn, roll a die as often as you like and add each result to a running total for this turn, the pot. If you roll a 1, the pot is lost and your turn ends. You may stop after any roll; the pot is then added to your score and your turn ends. The first player to reach 20 points wins.
Step 1: write down the facts
Before writing YAML, answer five questions from the rulebook:
| Question | Pig |
|---|---|
| How many players? | 2 |
| Competitive or cooperative? | Competitive |
| How does the game end, and who wins? | As soon as a player has 20 points or more; that player wins |
| What is the course of play? | Turns in order; within a turn, roll or stop until the turn ends |
| What does the rulebook not say? | Who starts. Whether you may stop before your first roll |
The last row becomes open_questions (step 9). Do not guess silently: a recorded question is
worth more than an invented rule that looks convincing.
Step 2: header
game: pig
label: Pig
players: "2 players"
num_players: 2
mode: competitive
summary: >
A push-your-luck dice game. On your turn you roll as often as you like and add the pips to a
pot. A 1 empties the pot and ends your turn; stopping banks the pot. First to 20 wins.
Players are numbered from 0: "player 1" in a rulebook is index 0.
Step 3: state
Everything that lies on the table or must be remembered. Pig has no cards, so there are no zones, only variables. Fixed numbers from the rulebook become constants, so they are easy to find and change.
constants:
GOAL: 20
state:
vars:
score: {scope: player, type: int, init: 0} # one value per player: score[0], score[1]
pot: {scope: global, type: int, init: 0}
current: {scope: global, type: ref, init: 0, note: "set by the turn loop"}
turn_over: {scope: global, type: bool, init: false}
current is the loop variable of the turn loop. turn_over is a flag the inner loop tests.
Step 4: flow, at diagram level
The flow is the skeleton of rounds, turns and phases. Pig is a loop over the players; inside each turn there is a start and then a loop of "roll or stop".
flow:
id: game
label: Game
kind: loop
over: all players in turn
as: current
order: fixed
children:
- id: turn
label: Turn
kind: sequence
children:
- {id: start, label: Start of turn, kind: step, do: start_turn}
- id: rolling
label: Roll until you stop or roll a 1
kind: loop
until: the turn is over
children:
- {id: choice, label: Roll or stop, kind: step, actor: the current player, do: [roll, stop]}
The outer loop has no until: after the last player it starts a new round with the first player,
and it keeps doing so until an end condition fires. A step with a list in do is a choice between
actions.
Step 5: actions, at diagram level
actions:
start_turn:
effect:
- {op: set, var: pot, value: 0}
- {op: set, var: turn_over, value: false}
roll:
effect:
- {op: roll, die: [1, 2, 3, 4, 5, 6], bind: pips}
- op: if
cond: a 1
then:
- {op: set, var: pot, value: 0}
- {op: set, var: turn_over, value: true}
else:
- {op: add, var: pot, amount: the pips}
stop:
pre: the pot is not empty
effect:
- {op: add, var: "score[current]", amount: the pot}
- {op: set, var: turn_over, value: true}
roll binds the result to the local name pips. stop has a condition (pre): stopping with an
empty pot is pointless, which also answers the second open question. Inside an action's pre,
cost and effect, the local name actor is the player who acts (the step's actor_x), so
score[actor] would work here as well as score[current].
Step 6: end
end:
- {id: reached_goal, if: the current player has reached the goal, result: win}
Step 7: validate
Paste steps 2 to 6 into the validator and press Check (or Ctrl+Enter). It shows:
- No errors, and no warnings. The model is a valid diagram.
- 43% executable, with rules 43%, content
-, steps 2, ops 9, code 0%, open questions 0. - What the engine still misses, the gap list:
== Pig: executable 43% (rules 6/14), 8 gaps
text: 8
action roll: if.cond
action roll: add.amount
action stop: pre
action stop: add.amount
flow game: loop.over
flow rolling: loop.until
… and 2 more
The two not shown are step.actor (who chooses in step choice) and end.if/who.
- Test games: 0 of 20 finished, with under "Where games stopped":
20× Not executable: action stop: pre is text without pre_x. The engine cannot decide whetherstopis legal, becausepreis text for people. - The flow as a tree (click to open), to compare with the rulebook:
loop Game for each current in all players in turn (no until)
sequence Turn
- Start of turn: start_turn
loop Roll until you stop or roll a 1 until the turn is over
- Roll or stop: roll | stop · by the current player
stop allowed if: the pot is not empty
"Open in the player" stays disabled until at least one test game finishes.
Step 8: add the expressions
Add an _x field next to every text field the gap report lists. Keep the text.
over: all players in turn
over_x: players # the list [0, 1]
...
until: the turn is over
until_x: turn_over
...
- {id: choice, ..., actor: the current player, actor_x: current, do: [roll, stop]}
...
cond: a 1
cond_x: "pips == 1"
...
- {op: add, var: pot, amount: the pips, amount_x: pips}
...
pre: the pot is not empty
pre_x: "pot > 0"
...
- {op: add, var: "score[current]", amount: the pot, amount_x: pot}
...
- {id: reached_goal, if: the current player has reached the goal, if_x: "score[current] >= GOAL", result: win, who_x: current}
Quote expressions that contain :, #, [, {, or start with a number or a quote.
Check again. Now the validator shows 100% executable (the gap list is gone), and:
Test games: 20 of 20 finished
On average 26 requests per game. How they ended:
reached_goal: 20×
Every game ends the way the rulebook says, and "Open in the player" is enabled.
Why who_x: current works: the engine checks the end conditions right after each step, before
the turn loop moves on, so current still holds the player who just stopped.
Step 9: play one game and read it
Press "Open in the player". The player opens with "Your model: Pig", random players and game (seed) 1. On the left is the track of the rules with the train at the current step; on the right, what is asked now, what was chosen and what happened until the next question; below, the table and a timeline. Step through the questions with the playback buttons.
Game 1 goes like this:
- The first question is already a die roll. Player 0's turn has started, and with an empty pot
only
rollis legal, so the engine took it without asking: one legal option is not a decision. - Question 1 is a chance at
action roll, out of 6 equally likely outcomes: a 1. Under "Effect until the next question" you see theifreset the pot and setturn_over; the inner loop ends and the train moves on to player 1's turn. "automatic" listsstep choice: player 1 → roll: player 1's first roll is not a decision either. - Player 1 rolls 6; at
step choice(optionsroll,stop) choosesrolland rolls 5, choosesrollagain and rolls 2, then choosesstop:scorebecomes[0, 13]. - After a few more turns player 1 stops with 25 points. The game ends after 15 questions with "End: reached_goal" and the payoff player 0 −1 · player 1 1.
"Zones and values" in the table shows score, pot, current and turn_over at every question.
"Game (seed)" picks the game: the same seed and the same players give the same game, and "Random
game" tries another one.
To try a specific line of play, play a seat yourself: under Players choose "You play player 0
(others: random)". The player stops at each of your decisions ("Your turn") and offers the options
as buttons. "Everyone by hand (one screen)" lets you make every choice. To try another goal,
change GOAL in the model and check it again.
Step 10: record what the rulebook does not say
open_questions:
- id: first_player
about: setup
question: Who starts? The rulebook does not say.
status: assumed
answer: Player 0 starts; the turn loop goes over players in index order.
status is open (undecided), assumed (the model follows the answer) or answered (the
source or the designer settled it).
Step 11: a player who is better than random
How a good player chooses is not part of the rules. A tactic is a separate file with rules per decision place:
tactic: pig-hold-at-8
game: pig
rules:
- at: step choice
when_x: "pot >= 8 or score[me] + pot >= GOAL"
pick: stop
- at: step choice
pick: roll
The built-in Pig in the player has this tactic. Choose "Pig (roll until you stop)" as the game and "tactic stop at 8 vs random" under Players, and follow its choices; or play a seat yourself against it. Loading your own tactic files is not yet available on the site.
Playing thousands of games to measure balance is not yet available on the site either. For the record: over 2000 games with random players the first player wins about 55% (a goal of 50 shrinks that advantage), and the tactic above wins about 75% against a random player.
The finished model
game: pig
label: Pig
players: "2 players"
num_players: 2
mode: competitive
summary: >
A push-your-luck dice game. On your turn you roll as often as you like and add the pips to a
pot. A 1 empties the pot and ends your turn; stopping banks the pot. First to 20 wins.
constants:
GOAL: 20
state:
vars:
score: {scope: player, type: int, init: 0}
pot: {scope: global, type: int, init: 0}
current: {scope: global, type: ref, init: 0, note: "set by the turn loop"}
turn_over: {scope: global, type: bool, init: false}
flow:
id: game
label: Game
kind: loop
over: all players in turn
over_x: players
as: current
order: fixed
children:
- id: turn
label: Turn
kind: sequence
children:
- {id: start, label: Start of turn, kind: step, do: start_turn}
- id: rolling
label: Roll until you stop or roll a 1
kind: loop
until: the turn is over
until_x: turn_over
children:
- {id: choice, label: Roll or stop, kind: step, actor: the current player, actor_x: current, do: [roll, stop]}
actions:
start_turn:
effect:
- {op: set, var: pot, value: 0}
- {op: set, var: turn_over, value: false}
roll:
effect:
- {op: roll, die: [1, 2, 3, 4, 5, 6], bind: pips}
- op: if
cond: a 1
cond_x: "pips == 1"
then:
- {op: set, var: pot, value: 0}
- {op: set, var: turn_over, value: true}
else:
- {op: add, var: pot, amount: the pips, amount_x: pips}
stop:
pre: the pot is not empty
pre_x: "pot > 0"
effect:
- {op: add, var: "score[current]", amount: the pot, amount_x: pot}
- {op: set, var: turn_over, value: true}
end:
- {id: reached_goal, if: the current player has reached the goal, if_x: "score[current] >= GOAL", result: win, who_x: current}
open_questions:
- id: first_player
about: setup
question: Who starts? The rulebook does not say.
status: assumed
answer: Player 0 starts; the turn loop goes over players in index order.
The workflow for any game
Work in this order; each step builds on the previous one. Validate after every step, not only at the end.
- Read the rulebook and write down the facts: number of players, competitive or cooperative, how the game ends and who wins, the course of a round (usually a section like "Playing the game"), and everything the rulebook leaves open.
- Header:
game,label,num_players,mode,summary(two or three sentences in your own words), optionallyplayers("2–4 players"). - State:
- things that lie somewhere (cards, tokens, pieces, cells) →
zones; - numbers, flags, whose turn it is →
vars(one per player:scope: player); - fixed numbers and tables from the rulebook →
constants; - values you can compute from the state →
state.derived; - a board with distances, lines of sight or paths →
boards(board geometry).
- things that lie somewhere (cards, tokens, pieces, cells) →
- Flow: rounds, turns and phases as a tree of
sequence,loop,branch,subflowandstep. Each step names one action, or a list of actions the actor chooses from. Follow the rulebook's headings: a phase in the rulebook is a node in the flow. - Actions: per action when it is allowed (
pre) and what it does (effect, a list of ops). - End: when the game stops and what every player gets.
- Reactions and exceptions: triggers (something happens automatically after an event), priority windows (players may play something in between), modifiers (a value is adjusted).
- Content and open questions: cards and dice faces as data (
content); every gap or ambiguity asopen_questions. - Expressions: add the
_xfields until the validator's list "What the engine still misses" is gone (or only lists what you deliberately leave as text). - Check: read the test games in the validator, follow single games in the player, compare with the rulebook (see checking a model).
Decision rules
Step, action or op?
- Step: a moment in the course of play ("draw a card", "choose an action"). It sits in the flow.
- Action: something that can be done, with a condition and an effect. A step chooses from actions.
- Op: one elementary operation inside an effect (set, add, draw, roll, choose).
A list in do, or choose?
- The player chooses which action (build, trade, pass):
do: [build, trade, pass]withactor_xon the step. Each action has its ownpreandeffect; the engine offers only the legal ones. - The player chooses a parameter of one action (which cell, which card, how many):
chooseinside the effect, with the options as a list or an expression. - The player chooses a subset (1 to 3 cards):
choosewithminandmax.
Zone or variable? Can you move it, draw it, or point at it on the table? Zone. Is it a number
or a status? Variable. A number per player is a variable with scope: player.
Flow step, trigger or window?
- It always happens at a fixed moment of the round: flow step.
- It happens in response to something ("whenever you take damage", "when a card is revealed"):
a trigger on an event (
emit+triggers). - Players may play something in between at a moment ("actions may be played after the quest phase", "in response you may cast a counterspell"): a priority window.
Constant or variable? Does it change during play? Variable. Is it a fixed setting of the game (target score, number of rounds)? Constant.
Rule or tactic? What the rulebook prescribes belongs in the model. How a good player chooses among legal options belongs in a tactic.
Patterns
Each pattern has a minimal snippet, cut from a small model that validates and plays. Snippets leave out declarations that do not matter for the pattern; the referenced files show the pattern in a complete model.
Taking turns
A loop with over_x: players and as: <variable> sets that variable for each player in turn;
the step uses it as actor_x. Without until_x the loop repeats: after the last player it starts
the next round with the first, round after round, until an end condition fires and ends the game.
The end check comes right after the step, before the loop moves on, so the as variable still
points at the player who just moved: "whoever just moved wins" is who_x: current.
Inside the action, actor is the acting player (the step's actor_x): write gold[actor] in
pre_x, cost and effect. Flow expressions and end conditions have no actor; they use the
loop variable.
flow:
id: game
kind: loop
over_x: players
as: current
children:
- {id: turn, kind: step, actor_x: current, do: [build, trade, pass_turn]}
Declare the as variable in state.vars (an undeclared one only exists once the loop has set
it). Example: pig.yaml.
Setup: a step without an actor
Setup ("shuffle the deck, deal two cards to each player") is a step without actor_x: a system
action. The engine runs it without asking anyone, because there is one action and nothing to
choose. It may roll, draw and shuffle (chance is fine), but it must not need a decision: a
choose with two or more options there needs its own actor_x.
flow:
id: game
kind: sequence
children:
- {id: setup, kind: step, do: deal} # no actor_x
- id: rounds
kind: loop
over_x: players
as: current
children: [{id: turn, kind: step, actor_x: current, do: [play, pass_turn]}]
actions:
deal:
effect:
- {op: shuffle, zone: deck}
- {op: for_each, items_x: players, as: p, body: [{op: draw, from: deck, to: "hand[p]", n: 2}]}
A round that stops halfway (bidding)
A turn loop always completes the round: until_x is only tested before the next round. If play
must stop in the middle of a round ("whoever passes loses the auction"), use a loop without
over_x, with your own turn variable that each action passes on, and a flag in until_x.
state:
vars:
bidder: {init: 0}
bid: {init: 0}
over: {init: false}
winner: {init: null}
flow:
id: auction
kind: loop
until_x: over
children:
- {id: bid_or_pass, kind: step, actor_x: bidder, do: [raise, pass_out]}
actions:
raise:
pre_x: "bid < 5"
effect:
- {op: add, var: bid, amount: 1}
- {op: set, var: bidder, value_x: "1 - bidder"}
pass_out:
effect:
- {op: set, var: winner, value_x: "1 - bidder"}
- {op: set, var: over, value: true}
end:
- {id: sold, if_x: over, result: score, returns_x: "[5 - bid if p == winner else 0 for p in players]"}
Example: auction.yaml.
Each player once
"Each player may engage one enemy": a loop with over_x: players, as: <variable> and
once: true. Without once the loop repeats the round until until_x holds. over_x may be any
list, for example the engaged enemies, with as: enemy.
- id: engagements
kind: loop
over_x: players
as: p
once: true
children:
- {id: engage, kind: step, actor_x: p, do: [engage, decline]}
Repeat until something happens; push your luck
A loop with until_x, tested only at the start of each round. Set a flag in an effect and test
it in until_x. "Roll or stop" is a step with do: [roll, stop] inside such a loop; give an action
that is pointless now a pre_x. See the tutorial.
A grid of cells
A grid of R × C cells can be one zone with one element per cell, for example init_x: "[None] * 9"
for 3 × 3 (cell r * 3 + c). place with at_x puts something in a cell (replacing what was
there). The cell is chosen with choose and options_x that computes the free cells. Fixed
patterns (lines, neighbours) are tables in constants, tested with any(...) and all(...). A free
cell is None; test it with is None.
constants:
LINES: [[0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6]]
MARKS: [X, O]
state:
zones:
board: {kind: grid, init_x: "[None] * 9"}
vars:
current: {init: 0}
derived:
line_done:
text: some line holds three equal marks
x: "any(board[a] is not None and board[a] == board[b] == board[c] for a, b, c in LINES)"
flow:
id: game
kind: loop
over_x: players
as: current
children:
- {id: turn, kind: step, actor_x: current, do: mark}
actions:
mark:
effect:
- {op: choose, options_x: "[i for i in range(9) if board[i] is None]", bind: cell}
- {op: place, what_x: "MARKS[actor]", to: board, at_x: cell}
end:
- {id: three, if_x: line_done, result: win, who_x: current}
- {id: full, if_x: "None not in board", result: draw}
The choose is inside the effect, so the step needs actor_x even though it has one action.
Examples: tictactoe.yaml,
neighbours.yaml.
A board with space: moving, range, line of sight
When distance, paths, sliding or line of sight matter (chess, draughts, dungeon fights), declare
the board in boards and track what is where: a variable {cell: piece}, or instances with a cell
property. The geometry functions compute the space (ray to the first piece, reachable with
costs and blockers, line_of_sight, shape for area attacks). Rules that recur ("is this cell
attacked", "where does this monster move") go into functions. Compute expensive things (the legal
moves) once after each move and keep them in a variable instead of recomputing them in every end
condition.
boards:
grid: {kind: square, width: 4, height: 4, neighbours: 4}
state:
vars:
pos: {init: "0,0"}
actions:
walk:
effect:
- {op: choose, options_x: "neighbours('grid', pos)", bind: to}
- {op: set, var: pos, value_x: to}
end:
- {id: home, if_x: "pos == '3,3'", result: win, who_x: "0"}
See board geometry; examples chess.yaml and hex-skirmish.yaml.
Deck, hand, hidden cards, secret choices
- Deck: a zone with
visibility: hidden,shuffled: trueandinit_xwith the cards. Drawing from a shuffled zone is a chance node; from a zone that is not shuffled you always get the first element. - Hand per player: a zone with
owner: player,visibility: owner; pathhand[i]in ops, andhand[i]is a list in expressions. - Secret choice:
secret: trueonchoose(or on the step). Show it later withrevealandwhat_x: everyone (orvisible_to_x) observes the value; the zone's visibility does not change. - A card nobody sees (set aside, face down): move or draw it into a zone with
visibility: hidden.
constants:
CARDS: [1, 2, 3, 4, 5, 6, 7, 8]
state:
zones:
deck: {kind: stack, visibility: hidden, shuffled: true, init_x: "CARDS"}
hand: {kind: set, owner: player, visibility: owner}
played: {kind: set, owner: player, visibility: hidden}
vars:
p: {init: 0}
flow:
id: game
kind: sequence
children:
- {id: deal, kind: step, do: deal}
- id: secret_turns
kind: loop
over_x: players
as: p
once: true
children:
- {id: play, kind: step, actor_x: p, do: play_card}
- {id: show, kind: step, do: reveal_all}
actions:
deal:
effect:
- op: for_each
items_x: players
as: q
body:
- {op: draw, from: deck, to: "hand[q]", n: 2}
play_card:
effect:
- {op: choose, options_x: "hand[actor]", bind: card, secret: true}
- {op: move, what_x: card, from: "hand[actor]", to: "played[actor]"}
reveal_all:
effect:
- op: for_each
items_x: players
as: q
body:
- {op: reveal, what_x: "played[q][0]"}
Players choose one after the other, but because the choice is secret and the card goes to a hidden zone, the second player cannot see the first card: this is how simultaneous secret choices are modelled. Examples: higher-lower.yaml, kuhn-poker.yaml.
Cards with their own state (instances)
A card, monster or unit that changes during play (damage, exhausted, tokens) is an instance: a
dict in a zone with a unique uid. Make instances with create from a content item (n: 3 for
three copies, with_x for initial state), or let a zone's init_x produce dicts. Change a
property with set/add and of_x + attr; read it as target['damage'].
content:
monsters:
goblin: {id: goblin, label: Goblin, hp: 3, status: verified}
actions:
spawn:
effect:
- {op: create, what_x: "content['monsters']['goblin']", to: board, n: 2, with_x: "{'damage': 0}"}
hit:
effect:
- {op: let, bind: target, value_x: "board[0]"}
- {op: add, of_x: target, attr: damage, amount: 2}
- {op: set, of_x: target, attr: exhausted, value: true}
- op: if
cond_x: "target['damage'] >= target['hp']"
then: [{op: move, what_x: target, from: board, to: discard}]
An instance keeps its properties when it moves. If it can come back (reshuffled, redrawn), reset damage and tokens where it leaves play. Example: stack-game.yaml.
Looking at, ordering and searching a deck
- "Look at the top 3":
lookwithn: 3andbind. Their order is fixed afterwards. - "Put it on the bottom" or "on top":
move,createordrawwithposition: bottomortop. - "Shuffle the deck":
shufflewithzone; what was known on top or at the bottom is unknown again. - "Search your deck for a card of type X":
searchwithfilter_x(the candidate isit, or the name inas), thenmoveand usuallyshuffle.
actions:
peek:
effect:
- {op: look, from: deck, n: 2, bind: top}
- {op: move, what_x: "top[0]", from: deck, to: deck, position: bottom}
dig:
effect:
- {op: search, from: deck, filter_x: "it % 2 == 0", bind: found}
- {op: move, what_x: found, from: deck, to: hand}
- {op: shuffle, zone: deck}
If nothing matches, search binds None (or [] with min/max), and the move after it
fails; guard it with an if. Example:
stack-game.yaml.
Dice
roll with die: [1, 2, 3, 4, 5, 6] (or die_x) and bind. Every face is equally likely; an
uneven die repeats faces ([0, 1, 1, 2]). Faces may be text (a coin: [heads, tails]) or
instances (die_x: "hand[p]" picks a random card from a hand). Several dice: several roll ops,
or a for_each/repeat.
Score and winner
| Rulebook | end |
|---|---|
| One winner | result: win, who_x: <index>; everyone else gets −1 |
| One loser | result: loss, who_x: <index>; everyone else gets +1 |
| Points | result: score, returns_x: "[...]" with one number per player |
| Draw | result: draw |
| Solo game | num_players: 1, mode: competitive, result: score with returns_x: "[score]" |
| Cooperative win / loss | mode: cooperative, result: win or loss without who_x: everyone +1 or −1 |
| A player drops out | result: eliminate with who_x (index or list); the game goes on |
With two players [1, -1], [-1, 1] or [0, 0] is the usual score; for a zero-sum game the
returns add up to 0. Returns may be larger than 1 (chips, points). utility: {min, max} documents
the range of returns; the engine does not use it. Examples:
auction.yaml,
coop-clock.yaml.
Eliminating players
result: eliminate adds players to eliminated; checking continues with the next condition, so
put the "last one standing" condition after it. Loops do not skip eliminated players by
themselves: filter them in over_x. A list in over_x is computed at the start of the round, so a
player eliminated halfway still gets that round's turn unless pre_x forbids it.
flow:
id: game
kind: loop
over_x: "[q for q in players if q not in eliminated]"
as: p
children:
- {id: turn, kind: step, actor_x: p, do: [poke_left, poke_right]}
end:
- {id: out, if_x: "True", result: eliminate, who_x: "[q for q in players if hp[q] <= 0]"}
- {id: last, if_x: "len(eliminated) == n_players - 1", result: win, who_x: "[q for q in players if q not in eliminated][0]"}
Priority windows do skip eliminated players.
Reacting to an event (triggers)
An emit op marks a moment ("damage"). Triggers with the same event react to it. timing:
when/before run before the impact, instead replaces the impact (the first one that fires),
after runs afterwards (default). optional: true makes it a use/skip choice for
controller_x.
actions:
hit:
effect:
- {op: let, bind: target, value: 1}
- op: emit
event: damage
impact:
- {op: add, var: "hp[target]", amount: -2}
triggers:
- id: block
event: damage
timing: instead
if_x: "shield[target] > 0"
do:
- {op: add, var: "shield[target]", amount: -1}
- id: cry_out
event: damage
optional: true
controller_x: target
do:
- {op: add, var: "rage[target]", amount: 1}
A trigger reads the local names of the place that emits (target here). If another place emits
the same event without binding that name, if_x fails with a NameError: make every emitter bind
the same names, or keep the value in a variable. Example:
triggers.yaml.
Playing in between (priority windows)
window: <name> on a flow node opens a window after that node. Actions with a speed that the
window allows can be played there; players get priority in turn and play a legal fast action or
pass. The protocol (priority.protocol) decides who starts (first_x), when the window closes
and how effects resolve: immediate or lifo_stack (on a stack; last played resolves first).
cost happens when played, effect when it resolves; cancel cancels an item on the stack, and
stack can be read in the window (in pre_x, cost and effect).
state:
vars:
hp: {scope: player, init: 3}
counters: {scope: player, init: 1}
priority:
protocol:
first_x: "0"
closes: all_pass_in_succession
resolution: lifo_stack
after_action: next
windows:
response: {label: Responses, allows: [fast]}
flow:
id: game
kind: loop
children:
- {id: attack, kind: step, do: attack, window: response}
actions:
bolt:
speed: fast
pre_x: "len(stack) == 0"
cost:
- {op: let, bind: target, value_x: "1 - actor"} # chosen when played, kept until it resolves
effect:
- {op: add, var: "hp[target]", amount: -1}
counter:
speed: fast
pre_x: "counters[actor] > 0 and any(s['actor'] != actor and not s['cancelled'] for s in stack)"
cost:
- {op: add, var: "counters[actor]", amount: -1}
# the topmost item of another player that is not cancelled yet; cost ops see `stack`
- {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} # `cancelled` becomes true only now
- Bind a target that is fixed when the action is played in
costwithlet. Names bound incostare kept until the effect resolves, andactoris then the player who played it. - A stack item's
cancelledturns true only when thecancelop runs, so in the effect: when the counter resolves, not when it is played. Until then another counter may target the same item. Cancelling it twice does no harm. - After an item resolves, priority goes back to the player
first_xgave when the window opened (it is not evaluated again). - Give fast actions a cost or a limit (
countershere). Free responses that are always legal can keep a window open forever (window does not closeafter 10,000 turns). - The engine checks the end after each effect that resolves, not when the window closes. If the
result depends on "the window is over", put
end_event: <event>on the node with the window and a trigger on that event that sets a flag: the end check right after the event sees it. A step after the window that sets the flag works too. - A node that only opens a window:
{id: combat, kind: sequence, children: [], window: combat}. passis offered by the engine; do not name a fast actionpass.
Examples: ambush.yaml, spell-duel.yaml.
Choosing a subset
choose with min and max: the engine asks for one element at a time, in the fixed order of the
options, plus done once min is reached, and binds the list of chosen options.
- {op: choose, options: [a, b, c, d], min: 1, max: 2, bind: cards}
In the player the first question is at action pick · 1 with the options a, b, c, d; after
picking b, the next is at action pick · 2 with c, d, done: only later options remain.
Example: subset.yaml.
Values changed by effects (modifiers)
Declare modifiers with stat, layer and change_x (where value is the value so far) and read
the value with modified('stat', base). Layers go from low to high. Temporary: the op modify with
until: <event>. On a content item: active while its active_x holds.
modifiers:
- {id: frenzy, stat: attack, layer: 1, if_x: "rage[attacker] > 2", change_x: "value + 2"}
actions:
strike:
effect:
- {op: modify, stat: attack, change_x: "value + 1", until: round_end}
- {op: add, var: "hp[1 - actor]", amount_x: "-modified('attack', 1, attacker=actor)"}
Keyword arguments of modified() (attacker=actor) are local names in if_x and change_x.
Example: modifiers.yaml.
Until the end of the phase or round
Put end_event: phase_end on every phase node (and end_event: [phase_end, round_end] on the
last one, or end_event: round_end on the round node), and use until: phase_end on modify. The
engine emits the event when the node is done, after its window. Triggers on that event do what
happens "at the end of the phase".
- id: round
kind: sequence
end_event: round_end
children:
- {id: planning, kind: step, do: plan, end_event: phase_end}
- {id: combat, kind: step, do: fight, end_event: phase_end}
Permission and costs that depend on cards
"You may only travel here if ...": read a yes/no value with modified('can_travel', True, card=c)
in pre_x and options_x, and let the card set it to False with a modifier. The same pattern fits
"cannot draw cards" (modified('draw_count', n)) and "does not ready" (modified('readies', True, card=c)). A cost that the card adds is a trigger on an event emitted just before the act
(traveling_to).
modifiers:
- {id: no_heal_on_odd_rounds, stat: can_heal, if_x: "round % 2 == 1", change_x: "False"}
actions:
heal:
pre_x: "modified('can_heal', True)"
effect: [{op: add, var: "hp[actor]", amount: 1}]
Campaign: scenarios with lasting state
- What lasts between scenarios:
persist: campaignon the variable or zone. - Start of each scenario: the op
reset; everything withoutpersistgets its initial value again (init_xis recomputed and may read lasting variables). - A scenario is a
subflow; an end condition withscope: <name>ends only that subflow and setsresults['<name>']towin,lossordraw.<name>is the key inflows. results['<name>']is cleared when that subflow starts again. Read it right after the subflow withresults.get('<name>'): a reward step after the subflow node pays out for a won scenario.- The scenario's inner loop needs no
until_x: the scoped end conditions end it.
A complete model (one player; swing gains gold and costs health, guard heals):
game: guide-campaign
label: Guide campaign
num_players: 1
state:
vars:
scenario_no: {init: 1, persist: campaign}
wins: {init: 0, persist: campaign}
gold: {init: 0, persist: campaign}
hp: {init: 2}
flows:
scenario:
id: scenario
kind: sequence
children:
- {id: setup, kind: step, do: setup}
- id: fight
kind: loop # no until_x: the scoped end conditions stop it
children: [{id: swing, kind: step, actor_x: "0", do: [swing, guard]}]
flow:
id: campaign
kind: loop
until_x: "scenario_no > 3"
children:
- {id: play_scenario, kind: subflow, ref: scenario}
- {id: tally, kind: step, do: tally}
actions:
setup:
effect: [{op: reset}] # hp back to 2; gold, wins, scenario_no stay
swing:
effect:
- {op: roll, die: [0, 1, 2], bind: loot}
- {op: add, var: gold, amount_x: loot}
- {op: add, var: hp, amount: -1}
guard:
pre_x: "hp < 2"
effect: [{op: add, var: hp, amount: 1}]
tally: # the reward step after each scenario
effect:
- op: if
cond: the scenario was won
cond_x: "results.get('scenario') == 'win'"
then:
- {op: add, var: wins, amount: 1}
- {op: add, var: gold, amount: 1} # reward: 1 extra gold
- {op: add, var: scenario_no, amount: 1}
end:
- {id: scenario_won, scope: scenario, if_x: "gold >= 3 * scenario_no", result: win}
- {id: scenario_lost, scope: scenario, if_x: "hp <= 0", result: loss}
- {id: campaign_over, if_x: "scenario_no > 3", result: score, returns_x: "[wins]"}
The order of events in one scenario: setup resets hp; after each swing or guard the end
check runs; when gold >= 3 * scenario_no the condition scenario_won sets
results['scenario'] = 'win' and ends the subflow; the campaign loop continues with tally,
which rewards the win; the end check after tally ends the game once scenario_no > 3.
Examples: expedition.yaml, campaign.yaml.
Skill or physics (outcome models)
What is not a rule but skill (a pool shot, a dexterity flick) becomes an outcome model: a
probability distribution over outcomes with p_x, which may depend on skill and difficulty. The op
sample draws an outcome and runs its effect.
constants: {SKILL: 0.6}
outcomes:
shot:
skill: true
derived_x:
p_pot: "SKILL * (1 - difficulty)"
outcomes:
- {id: potted, p: "p_pot", p_x: "p_pot", effect: [{op: add, var: score, amount: 1}]}
- {id: missed, p: "the rest", p_x: "1 - p_pot"}
actions:
shoot:
effect:
- {op: let, bind: difficulty, value: 0.25}
- {op: sample, model: shot}
Each outcome needs p (text, for the validator and the diagram) and p_x. Example:
eightball.yaml.
Cards and faces as data (content)
What is printed on cards belongs in content: catalogues of items (id, label, status,
text, and behaviour). exec with of_x runs an item's effect (self is the item).
| Card text | On the item |
|---|---|
| "When revealed", "Forced: after X", "Response: after X" | triggers on event X; optional: true + controller_x for "may" |
| "+2 attack per token", "while active: no cards are drawn" | modifiers with stat and if_x |
| "Action: ..." (played by a player) | abilities: an action with speed, playable in windows |
| "Sentinel", "Ranged" | keywords; the rules ask for them ('Ranged' in c.get('keywords', '')) |
content:
cards:
spider:
id: spider
label: Forest Spider
status: verified
text: "When revealed: each player loses 1 hp."
triggers:
- event: revealed
if_x: "card['id'] == self['id']"
do:
- {op: for_each, items_x: players, as: q, body: [{op: add, var: "hp[q]", amount: -1}]}
potion:
id: potion
label: Potion
status: verified
text: "Action: gain 1 hp."
abilities:
- {id: drink, speed: action, pre_x: "hp[actor] < 3", effect: [{op: add, var: "hp[actor]", amount: 1}]}
actions:
reveal:
effect:
- {op: create, what_x: "content['cards']['spider']", to: staging, bind: card}
- {op: emit, event: revealed}
- A trigger belongs to the card type, not to one copy: select the copy through the local names of
the event (
cardhere). Make sure every place that emits the event binds the same names. - "At this moment" ("each character currently questing gets −1"): record the
uids and pass them on,{op: let, bind: ids, value_x: "[c['uid'] for c in in_play if c.get('questing')]"}and thenmodifywithif_x: "card['uid'] in ids". Otherwise cards that arrive later are affected too. - An ability sees its card as
self; find the copy in play with[c for c in allies[actor] if c['id'] == self['id']]. status:verified,uncertainormissing.execon an item withstatus: missingor withouteffectisNotExecutable.
Examples, as rules structure in the diagram (their content and source are not published): The Lord of the Rings LCG (all card texts as triggers, modifiers and abilities) and Too Many Bones (content and open questions at diagram level). Small published examples: stack-game.yaml and triggers.yaml.
Rule procedures that cards call
When the same procedure recurs ("reveal an encounter card", "whoever has enough damage is
destroyed", "a full location is explored"), make it a trigger on an event of its own and emit that
event wherever it is needed. Cards then use the same procedure: a travel cost "reveal the top card"
is {op: emit, event: reveal_card}, damage from a card ends with {op: emit, event: damage}. No
card repeats the rules.
Pitfalls
- Text without
_xdoes nothing in the engine.pre: "has money"is for people; withoutpre_xthe engine stops withNotExecutable. For a loop'suntil/overand an end condition'sifit is worse: the engine silently ignores them. The validator lists them under "What the engine still misses". - Literal or expression.
value: opensets the text'open';value_x: openreads the variableopen.amountmust be a number oramount_x. In every other field text is a description: write a literal string as a quoted expression,what_x: "'sword'". - Players count from 0.
playersis[0, 1, ...]. - End conditions are checked only after a step that ran, after each resolved effect in a
window, and after each
end_event, in the order ofend; the first that holds decides. Not after a cost, not when a window closes, not in loops. A step with no legal action is skipped without a check. - A flow that runs out without an end condition ends with 0 for everyone and reason
flow finished without an end condition. Almost always a bug. - Drawing from a zone that is not shuffled always gives the first element. For randomness set
shuffled: true. The opshuffleonly forgets known positions; the randomness is in drawing. moveremoves the first equal element and fails if it is not there.- Local names (
bindofchoose,roll,let) live in the effect of that action, includingthen/else. Afor_eachbody, anemitimpact, triggers, outcome effects andexecwork on a copy: what they bind is gone afterwards. Use a variable to remember something. - YAML. Quote expressions that contain
:,#,[,{or start with a number. Models are read as YAML 1.1:yes,no,onandoffare booleans. As a key (on:) that is an error (key true is not text; put it in quotes); triggers useevent, a branch usesswitchas text. As a value, quote them when you mean text (options: ["yes", "no"]). - One option is not a choice. The engine takes a single option without asking. That is intended, but it changes the number of decisions.
- Competitive games need
who_xonwinandloss; cooperative games do not.scoreneedsreturns_xwith exactly one number per player. - A window without fast actions (no action with a
speedthe window allows) closes at once without asking anyone. - Who chooses. A
choosewith more than one option needs a chooser:actor_xon the op, oractor_xon the step. That holds even if the step has one action. - Names are visible. Action ids are the options players and agents see. Choose readable, stable ids.
- A trigger reads the local names of whoever emits. If another place emits the same event
without that name,
if_xfails with aNameError. - Instances keep their state. An instance keeps its properties in a discard pile. If it comes back, it still has damage or tokens; reset them where it leaves play.
- Text without behaviour counts as a gap. A content item with
textand no triggers, modifiers, abilities or used keywords is not executable. - The validator does not know your names. A misspelled variable in an expression passes
validation and fails as a
NameErrorin the test games or in the player. Always read the test games and follow a few games.
Checking a model
Do this after every step of the workflow, not only at the end. Everything runs in the browser; nothing is uploaded.
| Tool | Use it for |
|---|---|
| Validator | Paste the model and press Check: errors and warnings (structure, references, syntax, unknown keys), % executable, "What the engine still misses", 20 random test games and "The flow as a tree" |
| Player | "Open in the player" in the validator: one game, question by question. Change "Game (seed)" or press "Random game" for other games; play a seat yourself to test one line of play |
| Diagram | "Draw your own model": the flow as a track diagram, with markers for choice, chance, hidden information and windows per step |
What to look for:
- The validator shows no errors. Every warning is either a deliberate choice or a bug.
- "Test games: 20 of 20 finished", with the end reasons you expect and a plausible number of requests per game. Nothing under "Where games stopped", and no games that did not end within 5000 requests.
- Every action occurs in a few games; an action that never occurs usually has a wrong
pre_x. Follow games in the player with several seeds. - Every trigger fires. After each question the player shows the events and the triggers they set
off; a trigger whose event never appears, or whose effect never shows in the state, usually has a
wrong
if_xor listens to an event nobody emits (the validator warns about the latter). - Test card texts one by one: set up the situation (a small test model whose setup creates it), emit the event and check in the player that exactly what the text says happens.
- Known facts match: if the rulebook or the community gives game length or difficulty, compare. Win rates over thousands of games cannot be measured on the site yet.
- Put the flow ("The flow as a tree", or the diagram) next to the rulebook: does every heading of the course of play appear?
Checklist before you call a model done
-
game,label,num_players,mode,summaryare set. - Every rule in the rulebook appears in the flow, an action, a trigger, a modifier, content, or an open question.
- The validator shows no errors; every warning is understood.
- The validator shows 100% executable, or only gaps you list in
open_questionsor anote. - 20 of 20 test games finish, with the reasons you expect.
- A few games followed in the player, with different seeds, read like the rulebook.
- Every ambiguity is an
open_questionsentry with a status.