Docs: Board geometry

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:

ModelWhat it shows
chess.yamlSquare board with 8 neighbours and letter names; all move generation in functions, including castling, en passant and promotion
hex-skirmish.yamlHex 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
FieldOnDefaultMeaning
kindallsquaresquare or hex
width, heightsquare (required); hex as a rectangle–Integers
namessquarexyxy: cell "3,4". letters: cell "e4" (column a…z, row from 1; at most 26 columns)
neighbourssquare44 (orthogonal) or 8 (also diagonal)
radiushex–A hexagonal board around 0,0 with this radius
cellshex–A list of cells "q,r" (any shape)
missingall–Cells that do not exist: you cannot stand on, move through or see through them. Each must be a cell of the board
wallsall–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, noteall–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).

FunctionReturnsMeaning
cells(board)listAll cells, in cell order
directions(board)listThe direction names of this board
xy(board, cell)tupleCoordinates (x, y) (hex: (q, r))
cell(board, x, y)cell or NoneThe cell at these integer coordinates; None off the board or on a hole
step(board, cell, direction)cell or NoneOne step; None off the board, onto a hole, or through a wall
leap(board, cell, direction)cell or NoneLike step, but over walls (a knight's move is [1, 2])
ray(board, cell, direction, occupied?)listCells 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)listAdjacent cells, without walls and holes, in the order of directions
distance(board, a, b)integerHex: 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 NoneCheapest path: the cells after start, ending with goal. [] if start == goal
path_cost(board, start, goal, blocked?, costs?, through?)number or NoneCost of that path; 0 if start == goal
reachable(board, start, points, blocked?, costs?, through?)listCells reachable with at most points, cheapest first, without start
line_of_sight(board, a, b, corners?, blockers?)boolIs there a clear line from a to b
shape(board, cell, pattern, turns?)listThe 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.
  • path and path_cost give None if goal is unreachable, or is in blocked or through (unless start == goal). start itself is never checked against blocked.
  • reachable leaves out start and the through cells.
  • 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)"
FieldMeaning
paramsParameter names (^[A-Za-z][A-Za-z0-9_]*$). name? is optional (default None). Call by position or by name
letHelper values {name: expression}, computed top to bottom; each sees the parameters and earlier lets
xThe result (required)
text, noteFor 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.

  1. Attack cells: empty cells from which the monster can hit a hero (in_reach above).
  2. Focus: the hero with the cheapest attack cell (path_cost with enemies as blocked and allies as through).
  3. 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 blocked or blockers.

This page as Markdown