# Game board geometry and transformations
Internal reference for crate developers.
Read this before touching `src/game_board/transform.rs`, `src/game_board/parser.rs`'s resizing
hooks, or the `transformed_raw` / `is_rigid_translation` functions in `src/tessellation/`. It
explains the geometry those rest on, why the obvious implementations are wrong, and which parts are
deliberate choices rather than derived facts.
- [0. The one rule](#0-at-a-glance)
- [1. The one rule](#1-the-one-rule)
- [2. Coordinate geometry of the four tessellations](#2-coordinate-geometry-of-the-four-tessellations)
- [3. Rigid translations](#3-rigid-translations)
- [4. The transform maps](#4-the-transform-maps)
- [5. The transform engine](#5-the-transform-engine)
- [6. Resizing, trimming and cropping](#6-resizing-trimming-and-cropping)
- [7. Hexoban's half-column freedom](#7-hexobans-half-column-freedom)
- [8. What a transform invalidates](#8-what-a-transform-invalidates)
- [9. Why it looks like this](#9-why-it-looks-like-this)
- [10. The bug this replaced](#10-the-bug-this-replaced)
- [11. Verification](#11-verification)
- [12. Traps](#12-traps)
- [13. Where the code lives](#13-where-the-code-lives)
---
## 0. At a glance
`GameBoard` has rigid `mirror(MirrorAxis)` and tessellation-step `rotate(Rotation)` operations
backed by one coordinate-transform engine. Resizing is ultimately exact and batched rather than
rounded to a period: Trioban and Octoban preserve geometry during odd Top or Left shifts by adding
one perpendicular padding line, while Hexoban Top shifts use a specialized internal odd-r reindex.
`trim()` and `normalize()` crop all four edges in one operation so that compensation is chosen once
from the original rectangle. `enclose()` goes the other way, growing the edges the play area reaches
and then walling them, and it is the only operation that makes a board *closed*. Public docs warn
that all board editing invalidates positional IDs, Sokoban+, saved games, and Zobrist hashes.
For a non-empty Trioban or Octoban board:
| add/remove Top | width unchanged | add one left padding column |
| add/remove Left | height unchanged | add one top padding row |
| add/remove Bottom | exact, height only | exact, height only |
| add/remove Right | exact, width only | exact, width only |
For Hexoban, only `Top` operations need special handling: an odd batch reindexes alternating rows
and adds at most one column. Left is an ordinary integer translation; `Bottom` and `Right` do not
move retained cells. Sokoban never needs perpendicular compensation.
All public editing methods restore the width-normalization invariant. Once an operation empties a
board, later edge additions cannot grow it back; `GameBoard::grow` can, because it is not a
per-edge operation.
---
## 1. The one rule
Neither tile shape nor adjacency is stored anywhere. `TileShape` is derived from position by
`Tessellation::tile_shape_from_pos(x, y)`, and adjacency by `Tessellation::neighbor(..)`. A board's
geometry is therefore entirely a function of each tile's `(x, y)` index.
> A board transform is valid **iff** the coordinate map it applies is a rigid motion of the
> underlying tiling — an isometry carrying tiles onto tiles of the same shape.
Every transform is two parts: the geometric map itself (a mirror or rotation about the origin),
then a **re-anchoring translation** that moves the result back into non-negative coordinates. The
first part is rigid by construction. So the whole thing is rigid iff the translation is — and, as
§3 shows, only *some* index translations are rigid motions.
That single sentence is the origin of everything else in this document: the padding columns, the
parity conditions, the batching semantics of resizing, and the reason `body.reverse()` is not a
mirror.
---
## 2. Coordinate geometry of the four tessellations
All of this is derivable from `neighbor_checked()` in each `src/tessellation/<name>.rs`, and is
confirmed against it by the probe (§11). Side length 1, `h = √3/2`, screen coordinates — origin
top-left, **y grows downward**.
### Sokoban — square grid
Cell `(x, y)` centred at `(x, y)`. One tile shape. Four neighbours.
### Octoban — truncated square tiling
Cell `(x, y)` centred at `(x, y)`.
```
Octagon iff (x + y) is even
Square otherwise
```
Octagons have all 8 neighbours; squares only the 4 orthogonal ones —
`octoban::neighbor_checked` returns `InvalidDirectionForTile` for a diagonal from a square. The two
shapes are genuinely different, which is why Octoban has no escape hatch analogous to Hexoban's
(§7).
### Hexoban — pointy-top hexagons, odd-r offset
Implemented via odd-r / cube coordinates rotation. Odd rows are shifted right by half a tile:
```
centre_x = x + 0.5 * (y mod 2)
centre_y = y * h
```
Read it off `hexoban::neighbor_checked`: for even `y`, `NW = (x-1, y-1)` and `NE = (x, y-1)`; for
odd `y`, `NW = (x, y-1)` and `NE = (x+1, y-1)`. That is the odd-r convention. Every tile is a
`Hexagon`, so nothing can be *re-shaped* — what breaks under a bad transform is the row offset.
> This describes the **internal** dense form, which is what all transforms operate on. Hexoban's
> **external** text form (the interlocked `-#-#-` picture; see the module docs at the top of
> `src/game_board/hexoban.rs`) is a separate encoding used only for parsing and printing.
> The two differ by at least a global half-tile shift. Do not read the internal row-offset
> convention off the external ASCII, and do not implement geometry through the external form —
> that dance was removed precisely because it obscured this.
### Trioban — triangular grid
```
TriangleDown iff (x + y) is even
TriangleUp otherwise
```
`tile_shape_from_pos` computes `(x + (y % 2)) % 2`, which is the same thing. Column `x` spans
horizontally `[x/2, x/2 + 1]`, so consecutive columns in a row overlap by half:
```
centroid_x = x/2 + 0.5
centroid_y = y*h + h/3 for TriangleDown (flat top)
= y*h + 2h/3 for TriangleUp (flat bottom)
```
Check against `trioban::neighbor_checked`: from a ▽ at `(x, y)`, both `NE` and `NW` yield
`(x, y-1)` — the single △ sharing its flat top edge — while `SE`/`SW` alias onto `Right`/`Left`.
A triangle has 3 neighbours but 6 direction labels; this **direction aliasing** is why Trioban is
the awkward one in §8.
---
## 3. Rigid translations
| Sokoban | all | one tile shape, no row offset |
| Hexoban | `dy` even | odd rows carry a half-tile horizontal offset |
| Trioban | `dx + dy` even | shape is `(x + y) mod 2` |
| Octoban | `dx + dy` even | shape is `(x + y) mod 2` |
Implemented as `is_rigid_translation()` in each `src/tessellation/<name>.rs`.
Justification for the two "even" rows, in real coordinates:
- **Trioban.** The triangular tiling's translation lattice is generated by `(1, 0)` and `(0.5, h)`.
One index column is `0.5` horizontally, one index row is `h` vertically. So `(dx, dy) = (1, 1)`
is the lattice vector `(0.5, h)` ✓, `(2, 0)` is `(1, 0)` ✓, and `(0, 2)` is `2·(0.5, h) − (1, 0)`
✓ — exactly the `dx + dy` even set. An odd translation would land a ▽ where the index parity
says △.
- **Octoban.** The truncated-square tiling is generated by `(1, 1)` and `(1, −1)` in cell units:
diagonal moves preserve the octagon/square checkerboard, orthogonal ones do not.
- **Hexoban.** Shifting by an odd number of rows demands a compensating half-tile *horizontal*
shift, which is not expressible as an integer column shift. See §7 — this one has a way out that
the other two do not.
`Tessellation::translation_period()` reports the smallest axis-aligned integer translation
preserving each tiling:
| Sokoban | `1×1` |
| Hexoban | `1×2` |
| Trioban | `2×2` |
| Octoban | `2×2` |
**It is an explicit dispatch table, not derived from `is_rigid_translation`, and resizing does not
consult it.** It is informational — for a UI that wants to offer sensible granularity. Resizing
applies exact counts and compensates differently (§6). The four values are pinned by
`tessellation::tests::it_dispatches_transform_geometry_to_its_own_tessellation`.
---
## 4. The transform maps
All **unanchored** — they may return negative coordinates. Re-anchoring is the engine's job (§5).
Implemented as `transformed_raw()` per tessellation.
| Sokoban | `(-x, y)` | `(x, -y)` | `(-y, x)` |
| Octoban | `(-x, y)` | `(x, -y)` | `(-y, x)` |
| Hexoban | `(-x - (y mod 2), y)` | `(x, -y)` | odd-r → cube, `(x,y,z) → (-z,-x,-y)`, → odd-r |
| Trioban | `(-x - 2, y)` | `(x, -y - 1)` | `x' = ⌊(x−p)/2⌋ − ⌊3y/2⌋ − 1`,<br>`y' = ⌊(x+1−p)/2⌋ + ⌊(y+1)/2⌋`, `p = y mod 2` |
One rotation step is **90°** for Sokoban and Octoban, **60°** for Hexoban and Trioban — the rotation
subgroup of each tiling's symmetry group. Exposed as `Tessellation::rotation_step_degrees()`.
Octoban's step is not 45°: a 45° turn about an octagon centre would carry octagon-neighbours onto
square-neighbours, so it is not a symmetry.
### How each was derived
Write the real-coordinate transform, then solve for the new index. Reproduce these rather than
trusting them.
**Mirror about the vertical line `x = 0`:**
- Sokoban / Octoban: `centre_x = x`, so `x' = -x`. Octoban parity `(-x + y) ≡ (x + y)` ✓ — octagons
stay octagons, as they must.
- Hexoban: `x' + 0.5(y mod 2) = -(x + 0.5(y mod 2))`, and the row is unchanged, so
`x' = -x - (y mod 2)`.
- Trioban: `x'/2 + 0.5 = -(x/2 + 0.5)` ⟹ `x' = -x - 2`. A vertical mirror preserves triangle
orientation (▽ mirrored left-right is still ▽), and the parity agrees: `(-x-2+y) ≡ (x+y)` ✓.
**Mirror about the horizontal line `y = 0`:**
- Sokoban / Octoban: `y' = -y`. Octoban parity `(x - y) ≡ (x + y)` ✓.
- Hexoban: `y' = -y`; the row parity is unchanged so `x' = x`.
- Trioban: orientation **flips**. For a ▽ at `(x, y)`, `centroid_y = y·h + h/3`; its reflection is
a △, whose centroid is `y'·h + 2h/3`. Solving `-(y·h + h/3) = y'·h + 2h/3` gives `y' = -y - 1`.
The same substitution from a △ source gives `y' = -y - 1` too, so the map is uniform. Parity
`x + (-y-1) ≡ x + y + 1` — flipped, exactly as the orientation flip requires.
**Rotation, clockwise:**
- Sokoban / Octoban, 90°: `(x, y) → (-y, x)`. In screen coordinates (y down) this is the *visual*
clockwise turn — check on a 3×1 board: `(0,0) → (0,0)`, `(1,0) → (0,1)`, `(2,0) → (0,2)`, so the
leftmost cell ends up at the top ✓.
- Hexoban, 60°: through cube coordinates.
```
odd-r → cube: q = x - (y - (y mod 2)) / 2 ; r = y ; (cx, cy, cz) = (q, -q - r, r)
rotate 60° CW: (cx, cy, cz) → (-cz, -cx, -cy)
cube → odd-r: row = cz ; col = cx + (row - (row mod 2)) / 2
```
Use `rem_euclid(2)`, not `& 1`, so negative rows behave; the division numerator stays even
either way, so it is exact. Direction check on a 3×1 board: `(0,0) → (0,0)`, `(1,0) → (0,1)`,
`(2,0) → (1,2)` — leftmost at the top ✓, same sense as Sokoban.
- Trioban, 60°: the closed form above, with `div_euclid` so it floors for negatives. It was fitted
from a float embedding (rotate the centroid, snap back to the lattice) and then checked against
that embedding over a 40×40 region **including negative coordinates**: zero mismatches. Direction
check on a 3×1 board: `(0,0) → (-1,0)`, `(1,0) → (-1,1)`, `(2,0) → (0,1)` ✓ same sense.
**Counter-clockwise** is `M ∘ CW ∘ M`, where `M` is the vertical-mirror map. Conjugating a rotation
by a reflection whose axis passes through the rotation centre yields the inverse rotation, and in
all four tessellations it does. Sokoban and Octoban still spell it out directly as `(y, -x)`;
Hexoban and Trioban use the conjugation, which is why their `transformed_raw` has that shape. `M`
is an involution in every case — check Hexoban: `-(-x - (y mod 2)) - (y mod 2) = x` ✓.
Hand-check of the conjugation on Sokoban: `M(x,y) = (-x,y)`, `CW(x,y) = (-y,x)`, so
`M(CW(M(x,y))) = M(CW(-x,y)) = M(-y,-x) = (y,-x)` ✓, the inverse of `CW`.
---
## 5. The transform engine
`src/game_board/transform.rs`. Public surface is `MirrorAxis {Horizontal, Vertical}` and
`Rotation {Clockwise, CounterClockwise}`, driving `GameBoard::mirror()` and `GameBoard::rotate()`.
Internally everything funnels through `BoardTransform` and one function:
```rust
pub(super) fn transformed(body: &ParsedBoard, tessellation: Tessellation, transform: BoardTransform)
-> ParsedBoard
```
1. Assert width-normalization. Empty board in → empty board out and in this case `mirror` and
`rotate` are no-ops.
2. Map every cell through `transformed_raw`, tracking `min_x`, `min_y`, `max_x`, `max_y`.
3. **Anchor rule** (`best_anchor`). Start from the tight anchor `(-min_x, -min_y)`. Consider the
four candidates obtained by adding `(0,0)`, `(1,0)`, `(0,1)`, `(1,1)`. Discard those where
`is_rigid_translation(dx, dy)` fails — at least one always survives, since every predicate in §3
has period at most two on each axis. Among the survivors take the one minimising
`new_width * new_height`. The comparison is strict `<` over the candidate order, so **an exact
area tie adds a column, not a row.**
4. Allocate a `Floor`-filled grid of the resulting size and copy each tile to its image.
Two consequences worth remembering:
- **Not every destination cell has a preimage.** Hexoban's vertical mirror carries even and odd
rows onto interleaved column ranges, so the result legitimately has floor cells with no source.
That is not a bug.
- **`GameBoard::mirror()` and `rotate()` do not use the `match self.tessellation` dispatch** that
the other editing methods use — the tessellation is a parameter to the engine instead. Deliberate:
it keeps eight arms off the miswire surface.
Why the anchor rule is the interesting part: it is the **only free choice in the whole design**.
The maps are forced by geometry; the anchor is a convention. Minimum area with a documented
tie-break makes it deterministic, and `(1,0)` costing a column of `H` cells versus `(0,1)` costing
a row of `W` cells is why a fixed lexicographic order would have been wrong about half the time.
Transforms keep their complete bounding box and never inspect content. Repeated rotation therefore
grows the board — six 60° steps return the original content but not the original size. Tightening
is an explicit, separate `trim()`.
Numeric hygiene: the crate warns on `clippy::as_conversions`, `cast_*` and
`default_numeric_fallback`, so work in `i64` and convert with `try_from`, never `as`.
`unwrap`/`expect`/`panic` are also warned on, but `unwrap_or` is not — boards are tiny, so
saturating with a comment is the clean way through.
---
## 6. Resizing, trimming and cropping
`src/game_board/parser.rs`. This is where the implementation diverged most from the original design
(§9), so it is worth reading carefully.
### Exact counts, with perpendicular compensation
Resizing applies **one complete requested batch, exactly**:
- an addition to a non-empty board applies and returns exactly `count`;
- a removal applies and returns `min(count, available)`;
- removing the last row or column yields the canonical empty `0×0` board, with no padding;
- `add_rows`/`remove_rows`/`add_columns`/`remove_columns` all return `usize` — how many lines were
actually applied.
Only the **Top** and **Left** edges shift existing content, so only they can break geometry. Bottom
and Right are dimension-local and never need compensation.
For Trioban and Octoban, `(dx + dy)` must be even, so an odd shift is made rigid by adding one
**perpendicular** padding line:
| add/remove Top | width unchanged | one left padding column |
| add/remove Left | height unchanged | one top padding row |
| add/remove Bottom | exact, height only | exact, height only |
| add/remove Right | exact, width only | exact, width only |
In the code this is a single line per hook — for example in `add_rows_top`:
```rust
let extra_left = usize::from(!Self::TESSELLATION.is_rigid_translation(0, to_i64(count)));
*body = copied_rect(body, source, extra_left, count);
```
`BoardResizer` carries `const TESSELLATION: Tessellation;`, declared once per implementor. That is
a new arm of the miswire family; all four are pinned by test.
**Batching is observable, and that is intentional.** Adding two Top rows to a Trioban board in one
call adds no column; two separate one-row calls add two. A removal can shrink one dimension while
growing the perpendicular one by one. Callers control operation order — there is deliberately no
combined four-edge `resize(cols_left, cols_right, rows_top, rows_bottom)`. It existed and was
removed: with four edges changing in one call, the result depended on the order the four loops
happened to run in, and some orders collapsed the board to empty while others didn't. The four
`add_*`/`remove_*` methods take a `HorizontalEdge` (`Top`/`Bottom`, for rows) or a `VerticalEdge`
(`Left`/`Right`, for columns) plus a `usize` count, so sequencing is the caller's to choose. Two
consequences worth knowing:
- Removals saturate. `remove_rows(Top, 42)` on a 11-row board removes 11, it is not an error.
Removing every column removes the rows with it, per the invariant above.
- An emptied board cannot be grown back one edge at a time. Because `normalize_dimensions()` runs at
the end of *each* call, `add_rows` on an empty board produces zero-length rows that are
immediately collapsed away, and `add_columns` has no rows to add to.
### Growing is the one resize that is not per-edge
`GameBoard::grow(width: NonZeroU16, height: NonZeroU16)` takes each axis to
`max(current, wanted)`. It never shrinks, so a request below the current size on both axes is a
no-op, and it is the way back from the empty board.
It is the one resizing operation that takes no edge, and it needs none: it only ever extends
**Bottom** and **Right**, which "Exact counts, with perpendicular compensation" above establishes
as dimension-local in every tessellation. So it adds no perpendicular padding line, existing tiles
keep their `(x, y)`, and —
like the transform engine (§5) — it needs no `match self.tessellation`. The primitive is the free
`grown()` in `parser.rs`, alongside `normalize_dimensions()`, deliberately *not* a `BoardResizer`
method: a four-arm dispatch for a tessellation-independent operation would be a new arm of the
miswire family for nothing. The empty board needs no special case there either — it has no rows to
widen, and every row it then gains is built at the wanted width.
`NonZeroU16` caps a request at 65535 and `usize: From<u16>` is guaranteed, so no dimension
arithmetic here can overflow. The only saturation in play is the one `Size2D::size()` already
performs on `width * height`.
The discriminating test is `grow_adds_no_padding_and_moves_no_tile_in_any_tessellation`: it asserts
the exact resulting dimensions and that every pre-existing tile is still at its original
coordinates, across all four tessellations, for an odd growth on both axes. Reimplementing `grow`
through Top or Left would pass everything else and fail only that.
### Cropping is one operation, not four
`trim()` and `normalize()` compute **one source rectangle and reindex it once**:
- `trim()` finds the bounding rectangle of cells that are not `TileKind::is_empty_floor()` — walls,
boxes, goals and pushers all anchor it, so a goal-only or pusher-only edge line survives;
- `normalize()` uses a different anchor: it replaces non-playable cells and reconstructs significant
walls first, then crops the bounding rectangle of everything it retained — the playable area
*union* the significant walls. Both halves matter. Anchoring on
`StaticBoardAnalysis::significant_walls()` alone was a bug: a playable
tile on the board edge has no off-board neighbour that could become a significant wall, so it fell
outside that rectangle and was cropped away despite having just been retained. On a board whose
playable area does not touch the edge the two rectangles coincide, which is why it went unnoticed;
- `best_crop_offset()` picks the rigid anchor with the smallest resulting area, same tie-break as
the transform engine — a tie adds a column;
- a rectangle with no retained cells becomes the empty board and gets no compensation. `normalize()`
is the exception: an empty retained set means the board has no pusher, hence no playable area and
no significant walls, so there is nothing to normalize *around* and the board is left untouched
rather than floored flat.
This had to become a single operation. Once an odd Top or Left removal can add a perpendicular
line, counts computed before the first edit go stale and the result depends on the order the four
edges are processed in.
### Enclosing grows edges, and its order is pinned
`enclose()` is the inverse direction and answers a different question. `flood_fill()` stops at walls
**and at the board edge**, so the play area is always confined by the two together; a board is
*closed* exactly when the edge is doing none of that work
(`StaticBoardAnalysis::is_playable_area_confined()`). Nothing has to be reconstructed inward — every
on-board position beside the play area and not in it is already a wall, because that is why the flood
stopped there. So enclosing only has to make room:
1. grow one line on each edge the play area's bounding box touches, then
2. turn every `TileKind::is_empty_floor()` tile on the resulting outer edge into a wall.
Step 2 is what keeps it a single pass with no offset arithmetic: every line added in step 1 is floor,
and so is any perpendicular padding line Top or Left brought with it, so walling the outer edge seals
all of them without tracking where they landed. Growth only ever moves content *away* from an edge,
so an edge the play area did not touch is still untouched afterwards, and any non-floor tile
surviving on such an edge is dead content the play area cannot reach.
Unlike cropping, this one **is** processed edge by edge, so the order is fixed: **Bottom, Right, Top,
Left**. Bottom and Right never shift content and never add a padding line (§6), so doing them first
leaves the Top/Left padding decision to be made once on the final geometry. A board leaking both Top
and Left would otherwise land on different dimensions depending on order — the same order-dependence
that removed `resize()`. The sizes this produces for Sokoban, Trioban and Octoban are pinned by
`game_board::tests::test_enclosing::enclose_is_deterministic_when_two_edges_leak`.
Two consequences worth knowing: an already-closed board returns early, so `enclose()` is idempotent;
and dead floor on an edge that was not grown becomes wall as well, which is outside the play area and
so leaves the puzzle unchanged. The result is not tightened — follow with `trim()` or `normalize()`.
---
## 7. Hexoban's half-column freedom
Hexoban is the one tessellation that can shift by an odd number of rows, and understanding why is
the subtlest thing here.
Every Hexoban tile is a hexagon. An odd row shift only needs a compensating **half-tile horizontal**
shift — and a half-tile shift of the whole board is semantically a no-op, since only relative
geometry matters. Concretely, the re-indexing is:
```
(x, y) -> (x + (y mod 2), y + dy) for odd dy
```
which is a rigid motion but *not* an integer translation. It costs one column of width instead of
one row of height. Trioban and Octoban have no equivalent escape: their two tile shapes are
genuinely different, and no shift turns one into the other.
`HexobanBoardParser::reindexed_rect()` implements this directly on the dense internal board:
- even Top shifts preserve width;
- odd shifts offset alternating source rows by one column;
- if the retained rows span both parities, the bounding box gains one column;
- if only one row parity remains, its minimum offset is subtracted so the width stays tight;
- the calculation depends only on dimensions and row parity — **never on which cells happen to be
floor**.
The same helper serves odd Top addition, odd Top removal, and combined Hexoban cropping. Left,
Bottom and Right are ordinary. Parsing and printing still go through the external text form, but
editing no longer does.
Note the split this creates: `translation_period()` reports `1×2` for Hexoban because that is the
integer-translation fact, while Hexoban resizing *can* move by one row because it uses a
half-column-adjusted rigid motion instead. Both statements are true; they are about different
things.
The shared transform engine (§5) deliberately does **not** exploit this — it uses integer
re-anchoring only. Integer anchoring is sufficient for every mirror and rotation, and keeping the
engine uniform across tessellations was judged worth an occasional extra padding column.
---
## 8. What a transform invalidates
The rotation step is tessellation-specific.
A transform may add one row or column, because re-anchoring has to be a rigid translation. Mirroring
twice restores the original *content*, not necessarily the original size. A full turn of rotations
likewise (4 steps for Sokoban/Octoban, 6 for Hexoban/Trioban). Rotation grows the board to the
rotated bounding box and nothing trims it back — call `trim()` if you want it tight.
Editing is one edge at a time, removals saturate, and an emptied board cannot be grown back by a
further edge addition — only by `grow()`, which is not a per-edge operation.
Everything keyed to position. This is documented for users under `GameBoard`'s `# Editing game
boards` section; the detail behind it:
- **`BoxID` / `GoalID` / `PusherID` are positional.** Assigned by scanning row-major and
`.enumerate()`-ing (`GameBoard::boxes()`, `goals()`, `pushers()`). Any transform renumbers every
piece.
- **`SokobanPlus` is keyed to those IDs**, so it is silently invalidated. Nothing detects it.
- **`SavedGame` moves are directional** and no longer describe the board.
- **`ZobristHasher`** values (`manyban-engine`) are position-keyed and do not survive.
Nothing in the crate neither detects this nor prevents this invalidation. The transformation API is
board-only and this is intentional.
The whole thing rests on one idea, *a board transform is valid iff it is a rigid motion of the
underlying tiling.* Everything else (piece IDs, saved games, Zobrist hashes, ...) is bookkeeping.
Rotation / mirroring intended use is in two places: (1) designing a new puzzle, (2) comparing two
game boards in search of duplicates (or at least potential duplicates). If you are designing new
puzzle with `Sokoban+`, your `Sokoban+` design must come after the game board for that puzzle is
finalized and piece IDs become stable.
### Direction relabelling, if you ever do transform a saved game
For one clockwise step:
| Sokoban | `Up → Right → Down → Left → Up` (uniform, `+2 mod 8` in the `Direction` enum) |
| Octoban | same, plus `NE → SE → SW → NW → NE`; diagonals exist only on octagon cells |
| Hexoban | uniform 6-cycle `Right → SE → SW → Left → NW → NE → Right` — *not* a uniform enum shift, six directions in an eight-slot enum |
| Trioban | **not uniform.** Each source direction splits by triangle orientation, and each target is itself an aliased pair, e.g. `Left → Left, NorthWest` |
So Trioban is the one case that could never be a character substitution — it would need the replay
to know each step's tile orientation. That is the concrete reason saved-game transformation is out
of scope rather than merely unimplemented.
---
## 9. Why it looks like this
Decisions worth not re-litigating, and the ones that changed under implementation.
**Settled up front and unchanged:**
| Rotation API | one natural step per call, `rotate(Rotation)`, plus `rotation_step_degrees()` |
| Result size | never trim; a transform keeps its full bounding box |
| Mirror naming | `mirror(MirrorAxis)`; `Horizontal` is the top↔bottom flip, matching `HorizontalEdge {Top, Bottom}` |
`MirrorAxis::Horizontal` meaning the top↔bottom flip reads backwards to some people. The tie-breaker
was consistency with the existing `HorizontalEdge {Top, Bottom}` / `VerticalEdge {Left, Right}`
vocabulary.
**Revised during implementation:**
1. **Resizing applies exact counts instead of rounding to a period.** The original design rounded
Top/Left additions up and removals down, so `remove_rows(Top, 1)` on Octoban would return `0`.
Rejected in favour of applying the caller's edit exactly and compensating with a perpendicular
padding line — consistent with the already-accepted transform behaviour that dimensions may grow
to preserve the tiling, and it avoids an operation that silently does nothing.
2. **`translation_period()` was decoupled from resize granularity** and became informational (§3).
3. **`trim()`/`normalize()` crop once** rather than editing four edges in sequence (§6).
4. **Both axes got batched hooks.** `BoardResizer` now has `add_rows_top/bottom`,
`remove_rows_top/bottom`, `add_columns_left/right`, `remove_columns_left/right`. Sokoban, Trioban
and Octoban share `copied_rect()`; Hexoban overrides only the Top operations.
---
## 10. The bug this replaced
Historical, but keep it — it is the clearest illustration of §1, and the failure mode is easy to
reintroduce.
Historical naive approach was that `reverse_rows` / `reverse_columns` used to be `body.reverse()`
and `row.reverse()`. Measured behaviour before the fix:
| Sokoban | ok | ok |
| Hexoban | naive always wrong — was overridden | broken when height is **even** |
| Trioban | broken when width is **even** | broken when height is **odd** |
| Octoban | broken when width is **even** | broken when height is **even** |
Trioban's row condition is inverted relative to Octoban's because a top↔bottom mirror *flips*
triangle orientation, so Trioban needs the parity to flip while Octoban needs it preserved.
**Hexoban, even height.** Four-row board, `reverse_rows`:
```
BEFORE AFTER
-#-#-#-#- -#-#-#-#-
--#---$-# --#-@---#
-#-@---#- -#---$-#-
--#-#-#-# --#-#-#-#
```
A shear, not a mirror. `@` at internal `(1,2)` and `$` at `(2,1)` are two hexes apart before; after,
`@` at `(1,1)` and `$` at `(2,2)` are **neighbours**. The puzzle changed.
**Octoban, even width.** `"####|#@$.|####"`, `reverse_columns` → `"####|.$@#|####"`. The box was on
a `Square` at `(2,1)`; it lands on an `Octagon` at `(1,1)`, gaining four diagonal moves it did not
have.
**Why it survived for so long.** Both operations are self-inverse on the array, so every round-trip
test passed. And all four Hexoban mirror fixtures happened to use height 7 — odd, the case that
works. The lesson, still binding: *round trips prove nothing here.* Only adjacency preservation
discriminates.
---
## 11. Verification
### The acceptance property
`transform.rs`'s `every_transform_is_injective_and_preserves_adjacency`:
> For a transform `T` on a `W×H` board: `T` is **injective**, and for every cell `p`,
> `T(neighbours(p)) == neighbours(T(p)) ∩ image(T)`.
`neighbours(p)` is `{ tessellation.neighbor(p, d) : d ∈ known_directions() }`, filtered to `Some`.
Intersecting with the image is what makes the two sides comparable, since `neighbor` clips at the
board edge.
Run across all four tessellations × `{(4,4), (4,5), (5,4), (5,5), (7,3), (1,5)}` × all four
transforms. **The even/odd size mix is the whole point** — every bug in §10 is invisible at one
parity and fatal at the other.
Paired with `every_transform_preserves_the_multiset_of_non_floor_tiles`, which closes the one hole
adjacency leaves open: a copy loop that lands tiles on geometrically correct cells but carries the
wrong contents.
### The standalone probe
`geometry_probe.rs` is a small implementation depending on `sokoban-elements` by path and
using **only its public API**. It was written before the implementation existed and produced every
measured claim in this document, so it is a genuine independent check rather than a restatement of
the code.
- Part A — the naive index flips, the §10 table
- Part B — 90° rotation for Sokoban and Octoban, plain and parity-fixed.
- Part C — 60° Hexoban rotation through cube coordinates.
- Part D — 60° Trioban rotation, the integer-formula cross-check against the float centroid
embedding, checked against that embedding over a 40×40 region including negatives.
- Part E — the direction relabelling tables
- Part F — the finished public `GameBoard` API. It marks one source cell at a time to reconstruct
the actual mapping and runs `check()` for every transform, tessellation, parity, and test size.
- `check()` — the adjacency-preservation property of §7.1. **Lift this into the crate tests.**
Two notes. It anchors by "shift to non-negative, then bump to satisfy the parity constraint" — bump
**up** (`dx += 1`), not down, or cells go negative and the check reports a spurious out-of-bounds
failure. And it does not implement the minimal-area anchor rule; it takes the first valid candidate,
which is fine for a correctness probe but is not the behaviour the engine ships.
Recorded probe run. Part A deliberately applies the old naive index flips directly, so
its `BROKE` results remain as regression evidence. Part F exercises the finished public
API and must report only `OK`.
```
=== A. naive index flips (what BoardResizer's defaults do) ===
OK Sokoban 5x5 reverse_columns OK Sokoban 5x5 reverse_rows
OK Sokoban 4x5 reverse_columns OK Sokoban 4x5 reverse_rows
OK Sokoban 5x4 reverse_columns OK Sokoban 5x4 reverse_rows
OK Sokoban 4x4 reverse_columns OK Sokoban 4x4 reverse_rows
BROKE Hexoban 5x5 reverse_columns OK Hexoban 5x5 reverse_rows
BROKE Hexoban 4x5 reverse_columns OK Hexoban 4x5 reverse_rows
BROKE Hexoban 5x4 reverse_columns BROKE Hexoban 5x4 reverse_rows
BROKE Hexoban 4x4 reverse_columns BROKE Hexoban 4x4 reverse_rows
OK Trioban 5x5 reverse_columns BROKE Trioban 5x5 reverse_rows
BROKE Trioban 4x5 reverse_columns BROKE Trioban 4x5 reverse_rows
OK Trioban 5x4 reverse_columns OK Trioban 5x4 reverse_rows
BROKE Trioban 4x4 reverse_columns OK Trioban 4x4 reverse_rows
OK Octoban 5x5 reverse_columns OK Octoban 5x5 reverse_rows
BROKE Octoban 4x5 reverse_columns OK Octoban 4x5 reverse_rows
OK Octoban 5x4 reverse_columns BROKE Octoban 5x4 reverse_rows
BROKE Octoban 4x4 reverse_columns BROKE Octoban 4x4 reverse_rows
(representative failure detail)
Hexoban 4x4 reverse_rows (0,0)->(0,3): gained neighbour (1,2)
Octoban 4x4 reverse_columns (0,0)->(3,0): lost neighbour (2,1)
=== B. 90 degrees, Sokoban and Octoban ===
OK Sokoban all sizes, plain and parity-fixed
OK Octoban 5x5, 4x5 rot90 plain
BROKE Octoban 5x4, 4x4 rot90 plain -- (0,0)->(3,0): lost neighbour (2,1)
OK Octoban all sizes rot90 parity-fixed
=== C. 60 degrees, Hexoban (odd-r offset -> cube -> rotate -> offset) ===
OK Hexoban 5x5 rot60 -> 6x7 OK Hexoban 4x5 rot60 -> 5x6
OK Hexoban 5x4 rot60 -> 5x7 OK Hexoban 4x4 rot60 -> 4x6
OK Hexoban 7x3 rot60 -> 6x8
=== D. 60 degrees, Trioban (rotate centroids, snap back onto the lattice) ===
OK Trioban 5x5 rot60 -> 10x5 OK Trioban 4x5 rot60 -> 9x5
OK Trioban 5x4 rot60 -> 8x5 OK Trioban 4x4 rot60 -> 7x4
OK Trioban 6x3 rot60 -> 6x5
integer trioban rot60 formula: 0 mismatches over 1600 cells
=== E. how directions relabel under rotation ===
Sokoban rot90: Up -> Right, Right -> Down, Down -> Left, Left -> Up
Octoban rot90: same, plus NE -> SE, SE -> SW, SW -> NW, NW -> NE
(diagonals only defined on the 72 octagon cells of 144)
Hexoban rot60: Right -> SE, SE -> SW, SW -> Left, Left -> NW, NW -> NE, NE -> Right
(uniform over all cells)
Trioban rot60: NOT uniform -- each source direction splits 72/72 cells by triangle
orientation, and each target is itself an aliased pair, e.g.
Left -> Left|NW on 72 cells, NE|NW on the other 72
Right -> Right|SE on 72 cells, SE|SW on the other 72
```
### The raw maps, pinned per tessellation
`mod test_transforms` in each of the four `src/tessellation/*.rs` files. Before it existed the only
value-level assertion anywhere was the single point `(2, 1)` in `tessellation::tests`, which is an
anti-miswiring check and nothing more. Each module now pins, from the §4 formulas rather than from
the code: the images of both row and column parities; the images of negative coordinates, where
`rem_euclid`/`div_euclid` differ from `%` and `/`; `CW^(360/step) == id` **and not sooner**, with a
witness away from the origin; `CCW ∘ CW == id`; both mirrors as involutions; and
`is_rigid_translation` over all 49 pairs in `-3..=3` (49 for Sokoban, 21 Hexoban, 25 Trioban, 25
Octoban — the only difference between Sokoban's and Octoban's transform behaviour).
**Part E is now asserted, not just measured.** `it_rotates_every_direction_by_one_step` checks the
relabelling permutation for Sokoban, Octoban and Hexoban, and it is the only thing in the suite that
pins the rotation *angle*: adjacency preservation says the image of a neighbour is *some* neighbour,
this says *which*. Trioban is excluded on purpose — its relabelling is set-valued (§8) — and keeps
the adjacency half so the omission is visible.
Measured with deliberate perturbations, each reverted afterwards. The sharpest is a
**self-consistent 180° map** — `CW` and `CCW` both replaced by `(-x, -y)`, so they stay mutually
inverse. It is
injective, preserves adjacency and the tile multiset, and round trips both ways, so it passes
`every_transform_is_injective_and_preserves_adjacency`,
`every_transform_preserves_the_multiset_of_non_floor_tiles`,
`mirror_and_rotation_round_trips_restore_content` **and**
`counter_clockwise_inverts_clockwise_and_mirrors_are_involutions`. Of the whole pre-existing suite
only the one-point dispatch check notices it; it now fails seven of the new tests as well. That is
the concrete reason §12 says not to trust a round trip, and why the relabelling and full-turn tests
carry the weight here.
Dropping Hexoban's `y.rem_euclid(2)` term or Trioban's `-y - 1` is coarser and trips the property
tests too, but likewise fails the new per-tessellation ones first.
`best_anchor`'s rule is pinned in `transform.rs`'s `mod test_anchoring`: exact resulting dimensions,
the exact-area tie-break (relaxing `<` to `<=` fails those two tests and nothing else), and the
padding cells with no preimage that §5 calls "not a bug". Note the dimensions there are the
*engine's*, computed from §4 plus the minimal-area rule — not the figures in the probe run above,
which takes the first valid candidate instead.
Board level: `game_board/sokoban.rs` asserts a full rotated board as text, and
`game_board/{hexoban,trioban}.rs` mark one cell at a time and locate it after the turn — Part F's
idiom — because Hexoban's printed form is a normalizing separate encoding (§2) and a Trioban turn
legitimately changes the text's shape.
### Full tests suite
Beyond the transform property, the suite pins: exact return counts and cell coordinates for odd and
even Top/Left additions and removals; adjacency, injectivity and tile-shape preservation across
parity-sensitive tessellations and batch sizes; one batch versus repeated one-line calls; all-floor
boards, single-row/column boards, saturated removal and empty results; content-independent padding;
smallest-area crop selection and the column tie-break; combined Hexoban trimming and Trioban/Octoban
normalization; and text and serde round trips after dimension-changing edits.
---
## 12. Traps
- **Do not assert that `tile_shape_from_pos` is preserved by every transform** — assert the *signed*
table instead, which is strictly sharper. Shape is preserved by all four Octoban transforms and by
the 90° rotations, but on Trioban a 60° rotation and a top↔bottom mirror genuinely flip triangle
orientation (▽↔△), while a vertical mirror preserves it. That is a real tiling symmetry, and the
Trioban maps in §4 encode it deliberately. `trioban::tests::test_transforms` pins the flips and
`octoban::tests::test_transforms` pins Octoban's preservation, both on the parity `(x + y) % 2` —
**not** through
`tile_shape_from_pos`, which takes `usize` and cannot be handed the negative coordinates
`transformed_raw` produces. Adjacency remains the invariant that always holds.
- **Do not trust a round-trip test.** See §10.
- **Do not reintroduce geometry through Hexoban's external text form.** It is a serialization
concern; §2 and §7 explain why.
- **`rem_euclid` / `div_euclid`, never `%` and `/`,** in the raw maps. They operate on negative
coordinates, and Rust's `%` and `/` truncate toward zero.
- **Every `&mut self` method on `GameBoard` must call `normalize_dimensions()`.** It is the one way
the width-normalization invariant leaks, and it leaks silently. `enclose()` is the newest one that
has to. The exception is an early return that mutates nothing — `normalize()` and `enclose()` both
bail out on a board with no play area, and there the invariant already holds.
- **The `match self` arms in `src/tessellation.rs` are easy to miswire** — all four have the same
shape and the module names echo the variant names. Commit "fix: `Tessellation` dispatched
`Sokoban` and `Hexoban` to each other's module" fixed exactly that. `tessellation::tests` is the
only coverage of the dispatch itself.
- **A new public type must be added to the `pub use` list in `src/lib.rs`** or it is unnameable by
callers.