Tactics: players as a separate layer
A model describes the rules. A tactic describes how someone plays: at which decision they choose what. It is a separate YAML file next to the model, written in the same expression language. The engine runs it like any other player, so a tactic can be watched game by game, compared with another tactic, and get look-ahead on top.
On the site. The player has tactics for some of its built-in games: Pig ("stop at 8"), chess ("material") and the hex skirmish ("basic"). Pick one under Players and follow its decisions question by question. Loading your own tactic file is not yet available on the site, and neither are look-ahead or comparing tactics over many games. This page describes the tactic language, so that tactics can be written, read and shared now.
The JSON schema for editor completion is
tactic.schema.json; put
# yaml-language-server: $schema=https://tabletwin.com/models/docs/tactic.schema.json on the
first line of a tactic file. The published tactics are
pig-tactic.yaml,
chess-material.yaml and
hex-skirmish-basic.yaml.
The smallest example
For a Pig model with a step choice that offers roll and stop, a variable pot, a per-player
score and a constant GOAL (see pig.yaml):
keep rolling until the pot is 8, or until stopping wins.
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
Against a random player this tactic wins about 75% of games (measured over 2000 games). The finished example is pig-tactic.yaml: in the player, choose Pig and "tactic stop at 8 vs random" to watch it.
The file
| Key | Type | Default | Meaning |
|---|---|---|---|
tactic | text | ? | Name of the tactic |
game | text | game id of the model the tactic is for | |
label, summary, note | text | For people | |
extends | path | Base tactic, relative to this file (see variants) | |
let | {name: expression} | {} | Helper values, computed lazily per decision |
rules | list | [] | Rules, tried top to bottom |
default | random / first | random | What happens when no rule decides |
search | dict | {} | Places where look-ahead is used (see look-ahead) |
The schema requires tactic, and rules or extends.
Decision places
The engine asks each decision at a place. The name follows from the model:
| Place | When | Options |
|---|---|---|
step <id> | A step with several legal actions in do | action names |
action <name> | A choose or search in an action's effect (also an ability on a content item) | the options of the choice |
action <name> (cost) | A choose in an action's cost | idem |
trigger <id> | An optional trigger (use, skip), or a choose in a trigger's do | idem |
window <name> | A priority window | the playable fast actions and pass |
content <id> | A choose in a content item's effect run by exec | the options of the choice |
A subset choice (choose with min/max) asks one element at a time at <place> · 1,
<place> · 2, ...; tactics match the place without that suffix. A choose inside the impact of an
emit has the place <place of the emit> · <event>. The engine page has
the full list.
To see the places of a model, open it in the player: every question shows its place, its options
and who decides. A rule whose at names a place that never occurs is simply never used, so check
the spelling against what the player shows.
Rules
Rules are tried from top to bottom; the first rule that decides, picks. If none decides, default
does: random (a random option) or first (option 0).
| Field | Meaning |
|---|---|
at | Place, or list of places. Required. Shell-style wildcards: *, ?, [abc], [!abc] (window *, trigger *) |
options | The rule only applies if the option labels are exactly this list, in order ([use, skip]) |
when_x | The rule only applies if this expression is true |
pick | Pick the option with this label. If there is none, the rule does not decide |
pick_x | An expression that returns an option or a label; the option that is the same object or has the same label is picked. No match: the rule does not decide |
max_x / min_x | Score each option o (index i); the highest (lowest) wins, the first on a tie. A tuple compares field by field |
take_x | For subset choices: take candidate o? The first candidate for which it is true is picked; none: done; if done is not offered yet (fewer than min chosen): the first candidate |
note | For people |
Every rule needs exactly one of pick, pick_x, max_x, min_x, take_x, and an at;
otherwise the tactic cannot be loaded (NotExecutable: tactic <name>: rule 3 (...) has 2 pick fields).
rules:
- at: trigger guard_dog
options: [use, skip]
pick: use
- at: action attack
max_x: "(o['damage'], -o['cost'])" # most damage, then cheapest
- at: window *
when_x: "hp[me] <= 2"
pick: heal
- at: window *
pick: pass
Subsets. The engine asks a subset one element at a time: each time the remaining candidates
plus done (once the minimum is reached). take_x is computed per candidate, with pool (all
options of the choice) and chosen (what is already chosen). Compute what you want once in a let
and ask per candidate whether it belongs: take_x: "o['uid'] in questers".
What is in scope
| Name | What |
|---|---|
| Everything global in the model | Zones, variables, derived values, constants, content, players, n_players, eliminated, results, board and own functions, modified(...) |
opts, labels | The options of this decision (for a subset: the items behind the labels, None for done) and their labels |
o, i | In max_x, min_x, take_x: the option and its index |
pool, chosen | For a subset: all options of the choice, and what is chosen so far. Otherwise pool is opts and chosen is [] |
where, me | The place (without · n) and the deciding player |
names from let | Helper values, computed only when a rule uses them (also through another let) |
The local names of the engine at that moment (actor, stack and window in a priority window,
names bound by let or choose in the action) are not in scope; use me for the deciding
player. If a tactic needs to know what is on the stack, the model must keep it in a variable. A
tactic reads the full state, including hidden zones and other players' hands: not reading what the
player cannot know is up to the author.
The expression language has no lambda. Sort by a key with tuples:
[t[2] for t in sorted((-c['willpower'], i, c) for i, c in enumerate(pool))] (the index i
keeps the sort from comparing the cards themselves on equal keys).
Look-ahead (search)
A tactic can name places where a player looks ahead instead of following the rules blindly.
Look-ahead is not yet available on the site; this section describes what the search key means.
At the places in search.at the player compares every other option with the option the rules
pick:
- Each round takes
nnew random seeds (search.rollouts, default 8). - For each option and each seed it makes a copy of the state in which what the player cannot know is redistributed (see below), plays that option, and plays the game out with the tactic for all players. All options use the same seeds (paired).
- After each round, per alternative
a, withdthe per-seed differences betweenaand the rules' option,mtheir mean andseits standard error:m + z·se <= 0(worse, or not distinguishable):adrops out;m − z·se > 0(proven better):ais a candidate. If there are candidates, the one with the highest mean is chosen.
- When no alternative is left, or after
max_rolloutsrollouts per option, the rules' choice stands.
search:
at: [action commit, step defend] # places (wildcards allowed)
rollouts: 8 # seeds per round
max_rollouts: 32 # default 4 × rollouts
z: 2 # standard errors of proof needed to deviate from the rules (default 2)
Decisions with fewer than two options, and places not in search.at, are never searched.
Why a test. In a game like LotR LCG good and less good options differ by a few percentage points of win rate. That is less than the noise of a few dozen rollouts. "Take the highest mean" then picks by chance, and chance makes a good player worse. Measured: without the test the standard LotR tactic dropped from 25% to 5% wins, while a diagnosis with 200 rollouts per option showed that the rules often already picked the best option.
No peeking. A copy of the state knows everything, face-down cards included. So the search
first redistributes what the player cannot know. Drawing from a shuffled zone is already fair: the
order is only fixed when a card is drawn. Face-down cards that are already on the table (shadow
cards on an enemy) must be marked in the model with drawn_from:
shadow: {kind: set, visibility: hidden, drawn_from: encounter_deck}
The search then shuffles those cards together with the unknown part of the encounter deck and
deals them back. Counts and identities (uid) stay the same; only which card each one is changes.
Comparing tactics
The fair way to compare two players is on the same rules and the same seeds (paired), over hundreds or thousands of games, with a confidence interval on the difference in win rate. A variant that changes one rule then measures exactly that rule. Running such comparisons is not yet available on the site.
Variants with extends
Do not copy a tactic to change one thing; build on it with extends:
tactic: lotr-quest-wide
game: lotr-lcg
extends: lotr-standard.yaml
let:
quest_margin: "5" # the base uses 2
How the files merge:
rules: the variant's rules come first, then the base's;let: merged, the variant's values win;- every other key (
label,default,search, ...): the variant's value replaces the base's (searchas a whole, not per field); extendspaths are relative to the file that names them; a cycle is an error.
So put the numbers you want to vary in the base as let.
Testing a tactic
- Expressions in
letand in the rules must be valid expressions; every rule needs anatand exactly one pick field. - Every rule belongs to a place that exists: compare the
atvalues with the places the player shows. Places without a rule of their own fall back to general rules (window *) or todefault. - Watch it choose. For the built-in games with a tactic, pick the tactic under Players in the player and step through a game: at each question you see the options and what was picked. Play a seat yourself against it to try situations.
- If the same strategy exists elsewhere (as another tactic, or written down as rules of thumb), check that both take the same decisions in the same positions.
Pitfalls
- Order. The first rule that decides wins. Put specific rules (one trigger) above general ones
(
trigger *). pickon a missing label does not decide. The next rule gets its turn; put a catch-all at the bottom if that is not intended.- Optional triggers and choices in the same trigger share their place. Tell them apart with
options: [use, skip]. - One option is not a decision. The engine asks nothing when there is only one option; a rule for that place is then never used.
- A tactic is not a rule. What the rulebook prescribes belongs in the model. A tactic only chooses among what the rules allow.
- No engine locals in a tactic.
actor,stackand bound names give aNameError. Useme.