sashite-qi
An immutable, format-agnostic position model for two-player board games.
Overview
Qi models a board-game position as defined by the
Sashité Game Protocol. A position encodes
exactly four things:
| Component | Field(s) | Description |
|---|---|---|
| Board | board |
Flat slice of squares, indexed in row-major order |
| Hands | first_hand, second_hand |
Off-board pieces held by each player, as piece → count |
| Styles | first_style, second_style |
One style value per player side |
| Turn | turn |
The active player (First or Second) |
The type is generic over the piece type P and the style type S, so it is
independent of any notation. You choose how a piece and a style are represented —
a typed identifier, an interned id, a &str, a String, … Empty squares are
None.
This generality is what lets the Sashité notation crates build on it: the FEEN position format plugs in EPIN pieces and SIN styles, with no string round-tripping and no loss of type information.
Quick Start
use ;
// An empty 8×8 board. "C" / "c" are style identifiers (here: Chess for each side).
let position = new?
.board_diff? // kings
.board_diff? // rooks in the corners
.toggle; // hand over to the second player
assert_eq!;
assert_eq!;
assert_eq!;
assert_eq!;
# Ok::
Each transformation consumes the position and returns a new one, moving — not
copying — the storage. To keep a previous state, clone() it explicitly.
Installation
Or add it to Cargo.toml:
[]
= "0.1"
sashite-qi is no_std (it links only core and alloc), forbids unsafe,
and has no required dependencies. The minimum supported Rust version is
1.81.
Cargo features
serde(off by default) — implementsSerialize/DeserializeforQi<P, S>(requiresPandSto be (de)serializable). The wire form is the logical position — shape, board, hands, styles, turn — with each hand written as a sequence of(piece, count)pairs, so the output is portable across formats that restrict map keys to strings (JSON included). Decoding rebuilds the position through the validating constructor, so a deserializedQiupholds the same invariants as one built by hand. Enabling the feature keeps the crateno_std.
[]
= { = "0.1", = ["serde"] }
Usage
Construction
use Qi;
let _2d = new?; // 8×8
let _1d = new?; // 1D
let _3d = new?; // 3D
# Ok::
new starts every square empty (None), both hands empty, and the first player
to move. It validates the shape only (the piece and style types are checked by the
compiler, so there are no string-length or nil checks).
Placing and clearing pieces
board_diff addresses squares by flat index (see Board structure).
Each change is (index, Some(piece)) or (index, None).
use Qi;
let position = new?
.board_diff? // place a king on the centre square
.board_diff?; // move it off, place a queen on a1
assert_eq!;
assert_eq!;
assert_eq!;
# Ok::
Piece counts are tracked incrementally, so a diff costs time proportional to the number of changes, not to the board size.
Hands
Hands are piece → count maps. Each change is (piece, delta): a positive delta
adds copies, a negative delta removes them, zero is a no-op.
use Qi;
let position = new?
.first_hand_diff?
.first_hand_diff?; // remove a B, add a P
assert_eq!;
assert_eq!; // removed entirely
assert_eq!;
# Ok::
A move, end to end
The protocol does not prescribe how captures are modelled. board_diff does not
track what was previously on a square, so a captured piece is added to a hand
separately.
use Qi;
let start = new?
.board_diff?;
// First player captures on square 28 and slides their pawn there.
let after = start
.board_diff? // overwrite defender, vacate source
.first_hand_diff? // pocket the captured piece
.toggle; // hand over the turn
# Ok::
Accessors
| Method | Returns | Description |
|---|---|---|
shape() |
&[usize] |
Dimension sizes, outermost first |
dimension_count() |
usize |
Number of dimensions |
square_count() |
usize |
Total squares on the board |
piece_count() |
usize |
Pieces on the board plus both hands |
board_piece_count() |
usize |
Pieces on the board |
hand_piece_count() |
usize |
Pieces across both hands |
turn() |
Player |
The active player |
first_style() / second_style() |
&S |
Each player's style |
board() |
&[Option<P>] |
The board as a flat slice |
square(index) |
Option<&P> |
The piece at an index (None if empty/invalid) |
first_hand() / second_hand() |
impl Iterator<Item = (&P, usize)> |
Hand items in key order |
first_hand_count(&P) / second_hand_count(&P) |
usize |
Copies of a piece held (0 if absent) |
Constants
| Constant | Value | Description |
|---|---|---|
MAX_DIMENSIONS |
3 |
Maximum number of board dimensions |
MAX_DIMENSION_SIZE |
255 |
Maximum size of any single dimension |
MAX_SQUARE_COUNT |
65_025 |
Maximum total squares (255 × 255) |
Board structure
Shape and dimensionality
shape() returns the dimension sizes, outermost first. The number of squares is
their product, which must not exceed MAX_SQUARE_COUNT.
| Dimensionality | Constructor | shape() |
|---|---|---|
| 1D | Qi::new(&[8], …) |
[8] |
| 2D | Qi::new(&[8, 8], …) |
[8, 8] |
| 3D | Qi::new(&[5, 5, 5], …) |
[5, 5, 5] |
The total-square cap is independent of dimensionality: 3D boards are fully
supported as long as the product stays within the limit (e.g. 40×40×40 = 64_000
is fine; 255×255×2 is not).
Flat indexing
Squares are addressed by a single integer in row-major order.
1D with shape [f]: index = file.
2D with shape [r, f] (r ranks, f files): index = rank × f + file.
For a 3×3 board (shape [3, 3]):
file
0 1 2
┌────┬────┬────┐
rank 0 │ 0 │ 1 │ 2 │
├────┼────┼────┤
rank 1 │ 3 │ 4 │ 5 │
├────┼────┼────┤
rank 2 │ 6 │ 7 │ 8 │
└────┴────┴────┘
Square (rank = 1, file = 2) → index 1 × 3 + 2 = 5.
3D with shape [l, r, f] (l layers, r ranks, f files):
index = layer × (r × f) + rank × f + file.
Piece cardinality
The total number of pieces — board squares plus both hands — must never exceed the number of squares. For a board of n squares and p pieces: 0 ≤ p ≤ n. This invariant is checked on every transformation.
use ;
let full = new?
.board_diff?; // 2 pieces on 2 squares: OK
// A third piece (here in hand) would exceed the board.
assert_eq!;
# Ok::
Errors
All fallible operations return [Error]:
| Variant | Cause |
|---|---|
EmptyShape |
The shape had no dimensions |
TooManyDimensions |
More than MAX_DIMENSIONS dimensions |
DimensionTooSmall |
A dimension of size 0 |
DimensionTooLarge |
A dimension larger than MAX_DIMENSION_SIZE |
TooManySquares |
The product of the dimensions exceeds MAX_SQUARE_COUNT |
IndexOutOfRange |
A board_diff index is not a square on the board |
HandUnderflow |
Removing more copies of a piece than are held |
TooManyPieces |
The piece count would exceed the square count |
Construction validates the shape in order: dimension count, then each dimension size, then the total square count.
Generic over pieces and styles
P (piece) and S (style) are type parameters. The only trait bound is P: Ord,
required by the two hand-diff methods and the hand-count lookups (hands are
ordered maps); construction, board_diff, toggle, and the board accessors place
no bound on P or S. Debug, Clone, PartialEq, Eq, and Hash are derived
conditionally, so a Qi is comparable and hashable — usable as a position-identity
key — whenever its P and S are.
Any type works as a piece or style:
use Qi;
// Integer pieces, byte styles.
let position = new?
.board_diff?;
assert_eq!;
assert_eq!;
# Ok::
One ergonomic note: because new does not take a piece argument, the piece type
P cannot be inferred for a position that is never given a piece. In that case,
annotate it (let position: Qi<&str, &str> = Qi::new(&[8, 8], "C", "c")?;). In
normal use the first board_diff fixes P.
Design
- Immutable, move-based. Transformations consume the position and return a new
one, giving value semantics with zero-copy moves. Clone for a snapshot. A
Qiis safe to use as a map key, cache entry, or history record. - Bounded by construction. Dimensions, dimension sizes, total squares, and the
piece count are all bounded and checked, so a
Qiis safe to build from untrusted input with no extra sanitization. - Performance-oriented internals. The board is a flat
Vecfor O(1) indexed access; hands arepiece → countmaps; piece totals are maintained incrementally, so a diff costs O(changes), not O(board size). no_std, nounsafe, no required dependencies. Onlycoreandalloc; built under a forbid-unsafelint policy;serdeis an optional add-on.
Ecosystem
Qi is the positional core of the Sashité ecosystem. It
models what a position is (board, hands, styles, turn) without prescribing how
positions are serialized or what moves are legal:
- FEEN — a canonical string encoding for positions
- EPIN — piece token syntax
- SIN — style token syntax
- Game Protocol — the shared conceptual foundation
License
Available as open source under the terms of the Apache License 2.0.