Docs: Guide: from rulebook to model

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:

  1. What you build: one YAML file, two levels.
  2. Tutorial: a complete game, step by step, with what the validator and the player show.
  3. The workflow for any game, decision rules and patterns for everything Pig does not need.
  4. 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:

LevelYou writeYou get
DiagramStructure and text: pre: "has enough gold"The flow diagram and the rules overview on the site
ExecutableNext 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:

FileWhatPage
A tacticHow someone plays: rules per decision placetactics
A viewHow the table looks in the playerviews

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:

QuestionPig
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 whether stop is legal, because pre is 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 roll is 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 the if reset the pot and set turn_over; the inner loop ends and the train moves on to player 1's turn. "automatic" lists step choice: player 1 → roll: player 1's first roll is not a decision either.
  • Player 1 rolls 6; at step choice (options roll, stop) chooses roll and rolls 5, chooses roll again and rolls 2, then chooses stop: score becomes [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.

  1. 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.
  2. Header: game, label, num_players, mode, summary (two or three sentences in your own words), optionally players ("2–4 players").
  3. 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).
  4. Flow: rounds, turns and phases as a tree of sequence, loop, branch, subflow and step. 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.
  5. Actions: per action when it is allowed (pre) and what it does (effect, a list of ops).
  6. End: when the game stops and what every player gets.
  7. Reactions and exceptions: triggers (something happens automatically after an event), priority windows (players may play something in between), modifiers (a value is adjusted).
  8. Content and open questions: cards and dice faces as data (content); every gap or ambiguity as open_questions.
  9. Expressions: add the _x fields until the validator's list "What the engine still misses" is gone (or only lists what you deliberately leave as text).
  10. 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] with actor_x on the step. Each action has its own pre and effect; the engine offers only the legal ones.
  • The player chooses a parameter of one action (which cell, which card, how many): choose inside the effect, with the options as a list or an expression.
  • The player chooses a subset (1 to 3 cards): choose with min and max.

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: true and init_x with 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; path hand[i] in ops, and hand[i] is a list in expressions.
  • Secret choice: secret: true on choose (or on the step). Show it later with reveal and what_x: everyone (or visible_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": look with n: 3 and bind. Their order is fixed afterwards.
  • "Put it on the bottom" or "on top": move, create or draw with position: bottom or top.
  • "Shuffle the deck": shuffle with zone; what was known on top or at the bottom is unknown again.
  • "Search your deck for a card of type X": search with filter_x (the candidate is it, or the name in as), then move and usually shuffle.
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

Rulebookend
One winnerresult: win, who_x: <index>; everyone else gets −1
One loserresult: loss, who_x: <index>; everyone else gets +1
Pointsresult: score, returns_x: "[...]" with one number per player
Drawresult: draw
Solo gamenum_players: 1, mode: competitive, result: score with returns_x: "[score]"
Cooperative win / lossmode: cooperative, result: win or loss without who_x: everyone +1 or −1
A player drops outresult: 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 cost with let. Names bound in cost are kept until the effect resolves, and actor is then the player who played it.
  • A stack item's cancelled turns true only when the cancel op 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_x gave when the window opened (it is not evaluated again).
  • Give fast actions a cost or a limit (counters here). Free responses that are always legal can keep a window open forever (window does not close after 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}.
  • pass is offered by the engine; do not name a fast action pass.

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: campaign on the variable or zone.
  • Start of each scenario: the op reset; everything without persist gets its initial value again (init_x is recomputed and may read lasting variables).
  • A scenario is a subflow; an end condition with scope: <name> ends only that subflow and sets results['<name>'] to win, loss or draw. <name> is the key in flows.
  • results['<name>'] is cleared when that subflow starts again. Read it right after the subflow with results.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 textOn 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 (card here). 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 then modify with if_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, uncertain or missing. exec on an item with status: missing or without effect is NotExecutable.

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

  1. Text without _x does nothing in the engine. pre: "has money" is for people; without pre_x the engine stops with NotExecutable. For a loop's until/over and an end condition's if it is worse: the engine silently ignores them. The validator lists them under "What the engine still misses".
  2. Literal or expression. value: open sets the text 'open'; value_x: open reads the variable open. amount must be a number or amount_x. In every other field text is a description: write a literal string as a quoted expression, what_x: "'sword'".
  3. Players count from 0. players is [0, 1, ...].
  4. 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 of end; 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.
  5. 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.
  6. Drawing from a zone that is not shuffled always gives the first element. For randomness set shuffled: true. The op shuffle only forgets known positions; the randomness is in drawing.
  7. move removes the first equal element and fails if it is not there.
  8. Local names (bind of choose, roll, let) live in the effect of that action, including then/else. A for_each body, an emit impact, triggers, outcome effects and exec work on a copy: what they bind is gone afterwards. Use a variable to remember something.
  9. YAML. Quote expressions that contain :, #, [, { or start with a number. Models are read as YAML 1.1: yes, no, on and off are booleans. As a key (on:) that is an error (key true is not text; put it in quotes); triggers use event, a branch uses switch as text. As a value, quote them when you mean text (options: ["yes", "no"]).
  10. One option is not a choice. The engine takes a single option without asking. That is intended, but it changes the number of decisions.
  11. Competitive games need who_x on win and loss; cooperative games do not. score needs returns_x with exactly one number per player.
  12. A window without fast actions (no action with a speed the window allows) closes at once without asking anyone.
  13. Who chooses. A choose with more than one option needs a chooser: actor_x on the op, or actor_x on the step. That holds even if the step has one action.
  14. Names are visible. Action ids are the options players and agents see. Choose readable, stable ids.
  15. A trigger reads the local names of whoever emits. If another place emits the same event without that name, if_x fails with a NameError.
  16. 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.
  17. Text without behaviour counts as a gap. A content item with text and no triggers, modifiers, abilities or used keywords is not executable.
  18. The validator does not know your names. A misspelled variable in an expression passes validation and fails as a NameError in 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.

ToolUse it for
ValidatorPaste 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_x or 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, summary are 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_questions or a note.
  • 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_questions entry with a status.

This page as Markdown