Board geometry and own functions
A model can describe space. A board (boards) says which cells exist, which cells are
neighbours, and where walls and holes are. Board functions in the expression language compute
with it: neighbours, distance, sliding until the first piece, cheapest path, line of sight, area
patterns. Own functions (functions) bundle rules that come back often, such as "is this
square attacked" in chess or "which cell does this monster walk to" in a skirmish game.
Two fully executable examples, both playable in the player with their board drawn from the geometry:
| Model | What it shows |
|---|---|
| chess.yaml | Square board with 8 neighbours and letter names; all move generation in functions, including castling, en passant and promotion |
| hex-skirmish.yaml | Hex map with holes, obstacles and difficult terrain; monsters that pick a target, walk and check line of sight; an area attack with rotation |
1. Boards
boards:
board: {kind: square, width: 8, height: 8, names: letters, neighbours: 8}
map:
kind: hex
width: 7
height: 5
missing: ["3,0", "3,1", "2,3", "1,4"] # holes: these cells do not exist
walls: [["0,0", "1,0"]] # a wall between two neighbours
| Field | On | Default | Meaning |
|---|---|---|---|
kind | all | square | square or hex |
width, height | square (required); hex as a rectangle | – | Integers |
names | square | xy | xy: cell "3,4". letters: cell "e4" (column a…z, row from 1; at most 26 columns) |
neighbours | square | 4 | 4 (orthogonal) or 8 (also diagonal) |
radius | hex | – | A hexagonal board around 0,0 with this radius |
cells | hex | – | A list of cells "q,r" (any shape) |
missing | all | – | Cells that do not exist: you cannot stand on, move through or see through them. Each must be a cell of the board |
walls | all | – | Pairs of neighbouring cells with a wall between them: blocks step, ray, neighbours, paths and sight, not leap. On a square board only orthogonal neighbours can have a wall |
label, note | all | – | For people |
A hex board uses cells if given, else radius, else width and height.
Coordinates. Square: x to the right, y up; cell a1 (or "0,0") is x = 0, y = 0.
Hex: axial coordinates q,r with pointy tops; r grows downward. With width/height, odd rows
are shifted half a cell to the right so the map is a rectangle; the cell names stay axial (row 2
starts at q = -1, row 4 at q = -2).
Directions. Square with 4 neighbours: n e s w; with 8: n ne e se s sw w nw; n is
y + 1. Hex, clockwise from east: e se sw w nw ne (e is q + 1, se is r + 1, ne is
q + 1, r − 1). Wherever a direction is expected you may also give a vector [dx, dy] (hex:
[dq, dr]) of integers.
Cell order. cells(b) lists the cells row by row: square from y = 0 up, x ascending
(a1, b1, …); hex rectangle from r = 0 down; hex radius from r = −radius, q ascending;
hex cells in the given order. Holes are left out.
A board is fixed during play. What stands on it lives in the state: a variable
{cell: piece}, or instances in a zone with a cell attribute.
Errors in a board are validator errors, and NotExecutable when the engine loads the model:
board 'b': kind 'x'; use square or hex, ... width must be an integer, not None,
... names 'x'; use xy or letters, ... names letters allows at most 26 columns,
... neighbours 6; use 4 or 8, ... cells must be a list of 'q,r' cells,
... hex cell 'x'; write 'q,r' with integers, ... missing names unknown cell 'x',
... cell 'x' appears twice, ... wall [...]; write [cell, cell],
... wall at unknown cell 'x', ... wall between 'a' and 'b'; those cells are not adjacent.
The validator also warns about a board whose name never appears (in quotes) in any expression.
2. Board functions
Available in every expression when the model has boards. The first argument is always the
board name. Optional arguments (?) default to None and may be given by name:
path('map', a, b, costs=TERRAIN).
| Function | Returns | Meaning |
|---|---|---|
cells(board) | list | All cells, in cell order |
directions(board) | list | The direction names of this board |
xy(board, cell) | tuple | Coordinates (x, y) (hex: (q, r)) |
cell(board, x, y) | cell or None | The cell at these integer coordinates; None off the board or on a hole |
step(board, cell, direction) | cell or None | One step; None off the board, onto a hole, or through a wall |
leap(board, cell, direction) | cell or None | Like step, but over walls (a knight's move is [1, 2]) |
ray(board, cell, direction, occupied?) | list | Cells in that direction up to the edge, a hole or a wall, or up to and including the first cell in occupied |
neighbours(board, cell) | list | Adjacent cells, without walls and holes, in the order of directions |
distance(board, a, b) | integer | Hex: steps. Square: with 4 neighbours abs(dx) + abs(dy), with 8 max(abs(dx), abs(dy)). Ignores walls and holes |
path(board, start, goal, blocked?, costs?, through?) | list or None | Cheapest path: the cells after start, ending with goal. [] if start == goal |
path_cost(board, start, goal, blocked?, costs?, through?) | number or None | Cost of that path; 0 if start == goal |
reachable(board, start, points, blocked?, costs?, through?) | list | Cells reachable with at most points, cheapest first, without start |
line_of_sight(board, a, b, corners?, blockers?) | bool | Is there a clear line from a to b |
shape(board, cell, pattern, turns?) | list | The cells of a pattern placed at cell, rotated turns times clockwise |
occupied, blocked, through and blockers are anything that supports in: a list, a set,
or a dict (its keys).
Paths. Dijkstra over neighbours.
blocked: cells you may not enter (enemies, obstacles).through: cells you may pass but not end on (allies).costs:{cell: cost}, the cost of entering that cell (default 1; must be a number ≥ 0), e.g. 2 for difficult terrain.pathandpath_costgiveNoneifgoalis unreachable, or is inblockedorthrough(unlessstart == goal).startitself is never checked againstblocked.reachableleaves outstartand thethroughcells.- On equal cost the path found first wins (neighbours in the order of
directions), so the result is always the same.
Line of sight. The line runs from centre to centre. With corners true, the engine tries
lines from every corner of a to every corner of b (corners moved 5% toward the centre) and
there is sight if any one of them is clear. A line is blocked if a sample point falls off the
board or on a hole, if it crosses a wall between two consecutive cells, or if it enters a cell in
blockers (other than a and b). The line is sampled 8 times per unit of length (a square
cell is 1 wide; hex centres are √3 apart), so a line that grazes a corner exactly does not count
as blocked. a == b is always True.
Patterns. A list of directions or vectors, relative to cell. [[0, 0], 'e', 'se'] is a
triangle of three hexes. turns rotates clockwise by 60° (hex) or 90° (square); negative values
rotate counter-clockwise. Cells off the board or on holes are left out; duplicates appear once;
walls do not matter. All rotations: [shape('map', t, P, k) for k in range(6)].
Errors. Board function errors are ValueErrors and appear like any expression error
(error evaluating '...': ValueError: ...), for example:
cell 'z9' does not exist on board 'map', direction 'up' does not exist on board 'map',
direction [1]: a vector is [dx, dy] with integers, board 'mpa' does not exist,
x must be an integer, not 1.5, costs must be a dict {cell: cost}, not [...],
cost of '2,2' must be a number ≥ 0, not -1, reachable: points must be a number, not None,
shape: pattern must be a list of vectors or directions, not 'e', and for arguments:
path() is missing argument 'goal', path() has no argument 'cost',
path() got 'blocked' twice, step() takes at most 3 arguments, not 4.
With English names, take care not to hide a board function: a local name, variable or zone named
cell, step or path hides the function of that name (see
expressions.md § Names in scope). An own function may not
take such a name.
3. Own functions
functions:
enemy:
params: [k]
x: "'b' if k == 'w' else 'w'"
in_reach:
text: "within range (around walls) and in sight"
params: [a, b, reach]
let:
cost: "path_cost('map', a, b) if distance('map', a, b) <= reach else None"
x: "cost is not None and cost <= reach and line_of_sight('map', a, b, True)"
| Field | Meaning |
|---|---|
params | Parameter names (^[A-Za-z][A-Za-z0-9_]*$). name? is optional (default None). Call by position or by name |
let | Helper values {name: expression}, computed top to bottom; each sees the parameters and earlier lets |
x | The result (required) |
text, note | For people |
A function sees its parameters, its lets, the constants, content, the state (zones,
variables, derived values), modified, the board functions and the other functions (itself too:
recursion is allowed up to 100 calls deep). It does not see the local names of its caller
(actor, self, bound names): pass them as arguments. A parameter hides a variable or zone of
the same name. A function changes nothing; ops do that.
Functions work wherever an expression is allowed: pre_x, options_x, value_x, end
conditions, init_x, tactics. The name must not clash with a constant, variable, zone, derived
value, builtin, board function (when the model has boards) or reserved name; the validator and
the engine both reject it (function 'x': that name already exists). Other validator checks:
no x, params must be a list of names, parameter 'x' is not a name,
let must be a dict {name: expression}, and a warning for a function that is never called.
Argument errors look like those of board functions: in_reach() is missing argument 'reach'.
4. Patterns
Sliding pieces. With a variable pieces of the form {cell: piece} (a piece is text like
'wR', colour first) and colour the side to move, all rook moves from c:
[n for d in ['n', 'e', 's', 'w'] for n in ray('board', c, d, pieces) if pieces.get(n, '-')[0] != colour]
ray stops on the first occupied cell and includes it, so an own piece is filtered out and an
enemy piece can be captured. A knight: [n for v in [[1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, -1], [-2, 1], [-1, 2]] for n in [leap('board', c, v)] if n is not None].
Is a cell attacked? Look outward from the cell: knight leaps, king neighbours, pawn
diagonals, and per line the first piece (ray(...)[-1]). A function legal then drops the
moves after which the own king is attacked. chess.yaml
does it this way.
A monster walking to its target.
- Attack cells: empty cells from which the monster can hit a hero (
in_reachabove). - Focus: the hero with the cheapest attack cell (
path_costwith enemies asblockedand allies asthrough). - Destination: the cheapest attack cell within its movement (
reachable), else the cell that gets closest to one.
Area attack. For each cell in range and in sight, and each rotation, the targets in
shape(...); count each hit once.
[[c for c in shape('map', t, AREA, k) if c in occupied] for t in targets for k in range(6)]
(targets being the cells in range and in sight, AREA a pattern constant and occupied the
cells with monsters. Sets have no &; filter with in instead.)
hex-skirmish.yaml works these out in full.
5. Limits
- On a square board with 8 neighbours a wall only blocks the orthogonal step across it; a diagonal step past the end of a wall is still possible.
- Line of sight is sampled: a line that crosses less than an eighth of a unit through the corner of a hole can be missed.
- Boards are fixed. A map that changes during play (a door that opens) is modelled with a
variable passed as
blockedorblockers.