Skip to content

Movement & Piece Actions ​

For grid-based board games (Chess, Checkers, and similar), writing a separate top-level Action for every piece type gets repetitive fast — a Bishop, a Rook, and a Queen all "slide," they just differ in which directions they're allowed to slide along. ATLAS lets you describe this shape declaratively, directly on the piece, instead.

Pieces ​

pieces is the entity registry key used for movement-driven board games — it behaves identically to entities (same blueprint/instantiation rules, same properties bag), it's simply the conventional name when every blueprint in the array is a board piece.

yaml
pieces:
  - id: Knight
    symbol: N
    movement:
      - type: Leap
        direction: [2, 1]
        symmetry: Full
        condition: $target.is_empty or $target.has_enemy

id remains the canonical identifier used by selectors, actions, and mutations. An optional symbol gives compact board setup notation a declared token that resolves back to that ID; for example, symbol: N maps an N in a setup string to the Knight blueprint.

The Movement Grammar ​

Each entry in a piece's movement array describes one legal way that piece can travel. An entry combines a shape (type, plus direction/range) with a condition guard, and optionally mutations that run automatically when a move of that shape is taken.

Movement Types ​

TypeDescription
StepA single move of exactly range squares along direction (default range: 1).
SlideRepeated movement along direction, up to range squares (or Unlimited), stopping at the first occupied square.
LeapA fixed-offset jump described by a literal direction: [x, y] vector, ignoring any pieces in between. Add symmetry: Full to expand a single asymmetric offset into every rotation/reflection of it (e.g. the Knight's [2, 1] becomes all 8 L-shaped jumps) — see Symmetry.
CaptureThe same shape vocabulary as Step/Slide, but declared separately for pieces whose capture geometry differs from their normal movement (e.g. a Pawn's diagonal capture vs. its forward move).
EnPassantA specialized capture that reads the state of an adjacent square/entity rather than the destination square.

Direction ​

direction accepts a single value or a list (combining axes for pieces that move along more than one). A value is either one of the named presets below, or a literal [x, y] vector for a custom offset not covered by a preset (used by Leap, e.g. the Knight's [2, 1]):

  • Orthogonal: along the grid's rank/file axes.
  • Diagonal: along the four diagonal axes.
  • Omnidirectional: Orthogonal + Diagonal combined, for pieces like the Queen or King — equivalent to [Orthogonal, Diagonal] but preferred as a single readable value.
  • Forward / ForwardDiagonal: relative to the owning player's perspective — the engine mirrors these automatically per side, which is why a single Pawn definition works for both White and Black.
yaml
movement:
  - type: Slide
    direction: Omnidirectional
    range: Unlimited
    condition: $path.is_clear and ($target.is_empty or $target.has_enemy)

Directional Sets (Masks) ​

To keep the YAML human-readable, each named direction maps to a predefined set of [x, y] vectors ("mask") hardcoded in the engine — authors never write raw coordinate offsets for the common cases:

DirectionVectorsTypical piece
Orthogonal[0, 1] [0, -1] [1, 0] [-1, 0]Rook
Diagonal[1, 1] [1, -1] [-1, 1] [-1, -1]Bishop
OmnidirectionalOrthogonal + Diagonal (all 8 vectors)Queen, King

Orthogonal, Diagonal, and Omnidirectional are grid-relative — they mean the same thing regardless of which player owns the piece. Each is really just a base vector ([1, 0] or [1, 1]) expanded to its full rotational/reflective set — the presets are simply pre-expanded convenience values for the common cases; Leap uses the same direction field with a literal, un-expanded vector instead (see below).

Symmetry (Expanding a Vector) ​

When direction is given as a literal vector rather than a named preset — the case for Leap — add symmetry: Full to expand that single offset into its complete set of rotations and reflections, instead of only ever jumping along the one literal vector written:

yaml
pieces:
  - id: Knight
    movement:
      - type: Leap
        direction: [2, 1]
        symmetry: Full
        condition: $target.is_empty or $target.has_enemy

symmetry: Full on direction: [2, 1] produces the 8 Knight-style offsets ([2, 1] [1, 2] [-2, 1] [-1, 2] [2, -1] [1, -2] [-2, -1] [-1, -2]) — this is a generic geometric operation, not a piece-specific rule, so it applies equally to any asymmetric custom leap a game designer invents. Omit symmetry (or set it to None, the default) to keep the vector literal, e.g. for a piece that can only leap in one fixed direction. symmetry has no effect on the named presets (Orthogonal, etc.) — they're already fully expanded.

Player-Perspective Directions ​

Forward and ForwardDiagonal are the only masks resolved relative to the owning player instead of the raw grid — the engine flips the axis that points toward the opponent's back rank for each player, so the same piece definition covers every side:

DirectionWhite vectorsBlack vectors (mirrored)
Forward[0, 1][0, -1]
ForwardDiagonal[1, 1] [-1, 1][1, -1] [-1, -1]

This mirroring applies to any player role in a game with more than two players too — the engine derives "forward" per-player from each Player's configured facing/seat, not just a fixed White/Black pair.

Range ​

An integer number of squares, or Unlimited for slides that travel until they hit the edge of the board or a blocking piece.

Condition Context ​

condition uses the same boolean expression language used throughout ATLAS, plus a few accessors scoped to the move currently being evaluated:

  • self: the piece being moved (e.g. self.has_moved).
  • $target: the destination square/entity (e.g. $target.is_empty, $target.has_enemy).
  • $path: the squares between origin and destination, only meaningful for Slide (e.g. $path.is_clear).
  • $engine.state / $engine.board: broader read access for guards that need more than the immediate origin/destination (e.g. $engine.board.adjacent_enemy.en_passant_vulnerable).

Mutations ​

A movement entry's mutations list is applied automatically whenever that specific movement shape is the one selected/executed — for example, flagging a Pawn en_passant_vulnerable after its two-square opening move, or destroying the captured Pawn as part of an EnPassant capture.

yaml
pieces:
  - id: Pawn
    properties:
      has_moved: false
      en_passant_vulnerable: false
    movement:
      - type: Step
        direction: Forward
        range: 2
        condition: $target.is_empty and $path.is_clear and self.has_moved == false
        mutations: [ set self.en_passant_vulnerable = true ]
      - type: EnPassant
        direction: ForwardDiagonal
        range: 1
        condition: $target.is_empty and $engine.board.adjacent_enemy.en_passant_vulnerable == true
        mutations: [ destroy $engine.board.adjacent_enemy ]

Reactive Triggers ​

triggers is a terse, piece-scoped shorthand for "when X happens to this piece, do Y" — for simple reactions that don't need the full reactions/triggered actions machinery.

yaml
pieces:
  - id: Pawn
    triggers:
      - event: AfterMove
        condition: self.y == $board.final_rank(self.owner)
        action: Promote(Queen, Rook, Bishop, Knight)

Each trigger entry supports:

  • event: the lifecycle hook to react to (e.g. AfterMove).
  • condition: a boolean guard evaluated against the piece and its context.
  • action: the engine action to invoke when the condition is met, written as a function-call expression.

Entity-Owned Actions with Targets ​

Some moves — Castling being the canonical example — can't be expressed as a single origin/destination movement entry because they involve two pieces at once. For these, declare an ordinary entity-owned action with a targets list describing what else must be selected and validated:

yaml
pieces:
  - id: King
    properties:
      has_moved: false
    actions:
      - id: Castle
        condition: self.has_moved == false and $engine.state.is_in_check(self.owner) == false
        targets:
          - entity: Rook
            condition: >
              target.has_moved == false and
              target.owner == self.owner and
              $path.is_clear(self, target) and
              $engine.state.is_path_safe(self, target) == true
        mutations:
          - move self 2 steps toward target
          - move target 1 step past self final_position
  • targets entries constrain which other entity may be selected (entity) and under what condition (which can reference both self and target).
  • mutations for multi-piece actions like this may be written as short imperative move descriptions rather than a single operation/value pair, since the move can't be reduced to one entity's origin/destination.

Movement vs. Actions

Use movement for the common case — a single piece moving from one square to another under a shape rule. Reach for an entity-owned action with targets only when a move fundamentally involves more than one entity, like Castling.