FEEN (Field Expression Encoding Notation) implementation for Elixir.
This library implements the FEEN Specification v1.0.0, providing serialization and deserialization of board game positions between FEEN strings and Qi objects.
FEEN is a rule-agnostic, canonical position encoding for two-player, turn-based board games built on the Sashité Game Protocol. A FEEN string encodes exactly three fields: piece placement (board structure and occupancy), hands (off-board pieces), and style–turn (player styles and active player).
| Constraint | Value | Rationale |
|---|---|---|
| Regular shapes only | Required | All ranks must have equal length within each dimension |
| Max string length | 4096 | Sufficient for realistic board positions |
| Max board dimensions | 3 | Sufficient for 1D, 2D, 3D boards |
| Max dimension size | 255 | Fits in 8-bit integer; covers 255×255×255 boards |
These constraints enable bounded memory usage and safe parsing.
Only regular board shapes are supported — every rank within a dimension must contain the same number of cells. For example, 9x10 and 8x8 boards are valid. Irregular structures where ranks have different sizes (e.g., ranks of 3, 2, and 4 cells) are not supported.
# In your mix.exs
def deps do
[
{:sashite_feen, "~> 2.0"}
]
end{:qi, "~> 3.0"} # Position model
{:sashite_epin, "~> 1.2"} # Extended Piece Identifier Notation
{:sashite_sin, "~> 3.1"} # Style Identifier NotationConvert a FEEN string into a Qi object.
# Parse a Shōgi starting position
{:ok, position} = Sashite.Feen.parse("lnsgk^gsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGK^GSNL / S/s")
# The result is a Qi
position.shape
# => [9, 9]
position.board
# => {"l", "n", "s", "g", "k^", "g", "s", "n", "l",
# nil, "r", nil, nil, nil, nil, nil, "b", nil,
# "p", "p", "p", "p", "p", "p", "p", "p", "p",
# nil, nil, nil, nil, nil, nil, nil, nil, nil,
# nil, nil, nil, nil, nil, nil, nil, nil, nil,
# nil, nil, nil, nil, nil, nil, nil, nil, nil,
# "P", "P", "P", "P", "P", "P", "P", "P", "P",
# nil, "B", nil, nil, nil, nil, nil, "R", nil,
# "L", "N", "S", "G", "K^", "G", "S", "N", "L"}
Qi.to_nested(position)
# => [["l", "n", "s", "g", "k^", "g", "s", "n", "l"],
# [nil, "r", nil, nil, nil, nil, nil, "b", nil],
# ["p", "p", "p", "p", "p", "p", "p", "p", "p"],
# [nil, nil, nil, nil, nil, nil, nil, nil, nil],
# [nil, nil, nil, nil, nil, nil, nil, nil, nil],
# [nil, nil, nil, nil, nil, nil, nil, nil, nil],
# ["P", "P", "P", "P", "P", "P", "P", "P", "P"],
# [nil, "B", nil, nil, nil, nil, nil, "R", nil],
# ["L", "N", "S", "G", "K^", "G", "S", "N", "L"]]
position.first_player_hand # => %{}
position.second_player_hand # => %{}
position.first_player_style # => "S"
position.second_player_style # => "s"
position.turn # => :first
# Invalid input returns an error tuple
Sashite.Feen.parse("invalid") # => {:error, :invalid_field_count}
# Bang version (raises on invalid input)
position = Sashite.Feen.parse!("lnsgk^gsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGK^GSNL / S/s")Convert a Qi back to a canonical FEEN string.
# From an existing Qi
{:ok, position} = Sashite.Feen.parse("8/8/8/8/8/8/8/8 / C/c")
Sashite.Feen.dump(position)
# => "8/8/8/8/8/8/8/8 / C/c"
# From a Qi built manually
position = Qi.new([1, 8], first_player_style: "C", second_player_style: "c")
|> Qi.board_diff([{0, "K^"}, {7, "k^"}])
Sashite.Feen.dump(position)
# => "K^6k^ / C/c"# Boolean check (never raises)
Sashite.Feen.valid?("lnsgk^gsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGK^GSNL / S/s") # => true
Sashite.Feen.valid?("8/8/8/8/8/8/8/8 / C/c") # => true (empty board)
Sashite.Feen.valid?("k^+p4+PK^ / C/c") # => true (1D board)
Sashite.Feen.valid?("a/b//c/d / G/g") # => true (3D board)
Sashite.Feen.valid?("rkr//PPPP / G/g") # => false (dimensional coherence)
Sashite.Feen.valid?("invalid") # => false
Sashite.Feen.valid?(nil) # => falseFEEN parsing and dumping are perfect inverses — any valid FEEN string round-trips through Qi without loss.
# Chess starting position
feen = "-rnbqk^bn-r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/-RNBQK^BN-R / C/c"
{:ok, position} = Sashite.Feen.parse(feen)
Sashite.Feen.dump(position) == feen # => true
# Xiangqi starting position
feen = "rheag^aehr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RHEAG^AEHR / X/x"
{:ok, position} = Sashite.Feen.parse(feen)
Sashite.Feen.dump(position) == feen # => truePieces in hand are represented as count maps (%{String.t() => pos_integer()}) in Qi. FEEN automatically handles aggregation (for serialization) and expansion (for parsing).
# Shōgi mid-game with captured pieces
{:ok, position} = Sashite.Feen.parse("lnsgk^gsnl/1r5b1/pppp1pppp/9/9/9/PPPP1PPPP/1B5R1/LNSGK^GSNL P/p S/s")
position.first_player_hand # => %{"P" => 1}
position.second_player_hand # => %{"p" => 1}
# Multiple identical pieces are aggregated in FEEN
position = Qi.new([3, 3], first_player_style: "S", second_player_style: "s")
|> Qi.board_diff([{4, "K^"}])
|> Qi.first_player_hand_diff([{"P", 2}, {"B", 1}])
|> Qi.second_player_hand_diff([{"p", 1}])
Sashite.Feen.dump(position)
# => "3/1K^1/3 2PB/p S/s"Qi supports 1D, 2D, and 3D boards natively.
# 1D board
{:ok, position} = Sashite.Feen.parse("k^+p4+PK^ / C/c")
position.shape # => [8]
position.board
# => {"k^", "+p", nil, nil, nil, nil, "+P", "K^"}
# 3D board (2 layers × 2 ranks × 2 files)
{:ok, position} = Sashite.Feen.parse("ab/cd//AB/CD / G/g")
position.shape # => [2, 2, 2]
Qi.to_nested(position)
# => [[["a", "b"], ["c", "d"]],
# [["A", "B"], ["C", "D"]]]The FEEN style–turn field maps directly to Qi's style and turn accessors.
# First player to move (uppercase style is active)
{:ok, position} = Sashite.Feen.parse("8/8/8/8/8/8/8/8 / C/c")
position.first_player_style # => "C"
position.second_player_style # => "c"
position.turn # => :first
# Second player to move (lowercase style is active)
{:ok, position} = Sashite.Feen.parse("8/8/8/8/8/8/8/8 / c/C")
position.first_player_style # => "C"
position.second_player_style # => "c"
position.turn # => :secondA FEEN string consists of three fields separated by single ASCII spaces:
<PIECE-PLACEMENT> <HANDS> <STYLE-TURN>
-rnbqk^bn-r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/-RNBQK^BN-R / C/c
|----------------------------------------------------| |-| |---|
Piece Placement Hands Style-Turn
Encodes board occupancy as a stream of tokens organized into segments separated by /:
- Empty-count tokens: integers representing runs of empty squares (e.g.,
8= 8 empty squares) - Piece tokens: valid EPIN tokens (e.g.,
+K^,p,R')
Multi-dimensional boards use separator groups: // for layers (3D).
Encodes pieces held by each player, separated by /:
<FIRST-HAND>/<SECOND-HAND>
- Each hand is a concatenation of
[count]<piece>items - Count is optional (absent = 1, present ≥ 2)
- Empty hands are represented as empty strings
"/" # Both hands empty
"2P/p" # First has 2 pawns, second has 1 pawn
"3P2B/2p" # First has 3 pawns + 2 bishops, second has 2 pawnsEncodes native styles and active player:
<ACTIVE-STYLE>/<INACTIVE-STYLE>
- Each style is a valid SIN token (single ASCII letter)
- Uppercase = Side
first, lowercase = Sidesecond - Position determines who is active
"C/c" # First player (Chess-style) to move
"c/C" # Second player (Chess-style) to move
"S/s" # First player (Shogi-style) to move
"M/c" # First player (Makruk-style) vs second player (Chess-style), first to move# Starting position
"-rnbqk^bn-r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/-RNBQK^BN-R / C/c"
# After 1.e4
"-rnbqk^bn-r/+p+p+p+p+p+p+p+p/8/8/4P3/8/+P+P+P+P1+P+P+P/-RNBQK^BN-R / c/C"
# After 1.e4 c5 (Sicilian Defense)
"-rnbqk^bn-r/+p+p1+p+p+p+p+p/8/2p5/4P3/8/+P+P+P+P1+P+P+P/-RNBQK^BN-R / C/c"# Starting position
"lnsgk^gsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGK^GSNL / S/s"
# After 1.P-7f
"lnsgk^gsnl/1r5b1/ppppppppp/9/9/2P6/PP1PPPPPP/1B5R1/LNSGK^GSNL / s/S"# Starting position
"rheag^aehr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RHEAG^AEHR / X/x"# Chess vs Makruk hybrid
"rnsmk^snr/8/pppppppp/8/8/8/+P+P+P+P+P+P+P+P/-RNBQK^BN-R / C/m"# Parses a FEEN string into a Qi.
# Pieces on the board are EPIN token strings; empty squares are nil.
# Returns {:ok, %Qi{}} or {:error, reason}.
@spec Sashite.Feen.parse(String.t()) :: {:ok, Qi.t()} | {:error, atom()}
# Parses a FEEN string into a Qi.
# Raises ArgumentError if the string is not valid.
@spec Sashite.Feen.parse!(String.t()) :: Qi.t()
# Reports whether string is a valid FEEN position.
# Never raises; returns false for any invalid input.
# Uses an exception-free code path internally for performance.
@spec Sashite.Feen.valid?(term()) :: boolean()
# Serializes a Qi to a canonical FEEN string.
# Board pieces must be valid EPIN token strings.
# Style values must be valid SIN token strings.
@spec Sashite.Feen.dump(Qi.t()) :: String.t()Sashite.Feen.max_string_length() # => 4096Parsing errors are returned as atoms in {:error, reason} tuples:
| Atom | Cause |
|---|---|
:not_a_string |
Input is not a binary |
:input_too_long |
String exceeds 4096 bytes |
:non_ascii_input |
Non-ASCII bytes detected |
:invalid_field_count |
Not exactly 3 space-separated fields |
:piece_placement_empty |
Field 1 is empty |
:piece_placement_starts_with_separator |
Field 1 starts with / |
:piece_placement_ends_with_separator |
Field 1 ends with / |
:empty_segment |
Empty segment between separators |
:invalid_empty_count |
Empty count is zero or has leading zeros |
:invalid_piece_token |
Token is not a valid EPIN identifier |
:board_not_regular |
Ranks have different sizes within a dimension |
:dimensional_coherence_violation |
Separator depth mismatch |
:exceeds_max_dimensions |
Board has more than 3 dimensions |
:dimension_size_exceeds_limit |
A dimension exceeds 255 squares |
:invalid_hands_delimiter |
Field 2 missing / or has multiple |
:invalid_hand_count |
Multiplicity is 0, 1, or has leading zeros |
:hand_items_not_aggregated |
Identical EPIN tokens not combined |
:hand_items_not_in_canonical_order |
Items violate ordering rules |
:invalid_style_turn_delimiter |
Field 3 missing / or has multiple |
:invalid_style_token |
Token is not a valid SIN identifier |
:style_tokens_same_case |
Both tokens same case |
:too_many_pieces |
Total pieces exceeds total squares |
The bang variant parse!/1 raises ArgumentError with descriptive messages.
FEEN output is always canonical:
- Empty-count tokens use minimal base-10 integers (no leading zeros, consecutive empties merged)
- Hand items are aggregated and sorted deterministically:
- By multiplicity (descending)
- By EPIN base letter (case-insensitive alphabetical)
- By EPIN letter case (uppercase before lowercase)
- By EPIN state modifier (
-before+before none) - By EPIN terminal marker (absent before present)
- By EPIN derivation marker (absent before present)
# Non-canonical input is rejected
Sashite.Feen.parse("8/8/8/8/8/8/8/8 PpP/p C/c")
# => {:error, :hand_items_not_aggregated}This library is designed for backend use where inputs may come from untrusted clients. The implementation enforces zero attack surface through bounded resource consumption and defensive parsing.
All inputs are rejected at the byte level before any allocation occurs:
- Length check first: Inputs longer than 4096 bytes are rejected with a single
byte_size/1check before any parsing begins. This cap is sufficient for any realistic board position (a 255×255 board with all pieces would produce ~1500 bytes). - No regex engine: All parsing uses raw binary pattern matching (
<<byte, rest::binary>>), eliminating ReDoS as an attack vector. - No
Stringmodule on the hot path: Parsing operates on raw binaries.String.trim/1,String.split/2,String.contains?/2, andString.at/2are never called on untrusted input. - Bounded numeric parsing: Empty-count and hand-count digit sequences are bounded by the maximum FEEN string length. Integer overflow is impossible.
The BEAM atom table is finite and not garbage-collected. This implementation never calls String.to_atom/1 at runtime. Every atom in the output structs comes from compile-time literals in the EPIN and SIN dependencies. Untrusted input cannot introduce new atoms into the system.
Any input that is not a valid, canonical FEEN string is rejected with an {:error, atom()} tuple. The rejection path does not raise exceptions, does not allocate atoms, and does not allocate intermediate strings.
| FEEN spec requirement | Section | Status |
|---|---|---|
| Syntactic validation | §11.1 | ✓ Enforced |
| Canonicality validation | §11.2 | ✓ Enforced (empty-counts and hand ordering) |
| Dimensional coherence | §11.3 | ✓ Enforced |
| Cardinality constraints | §11.4 | ✓ Enforced (n ≥ 1, p ≤ n) |
| Robustness limits | §12 | ✓ Enforced (4096 bytes, 3 dimensions, 255 per axis) |
- Spec conformance: Strict adherence to FEEN v1.0.0, including all MUST-level validation
- Qi integration: Parses to and dumps from
Qi, the shared position model across Sashité libraries - Bounded input first:
byte_size > 4096is the first check; no code path processes unbounded input - Binary pattern matching on the hot path: Parsing operates on
<<byte, rest::binary>>destructuring — noStringmodule, no regex - Zero dynamic atoms: All atoms are compile-time literals via EPIN and SIN dependencies
- Zero external parsing dependencies: EPIN and SIN validation is inlined for performance; only
Qiis required at runtime - Canonical output:
dump/1always produces canonical form - Elixir idioms:
{:ok, _} | {:error, _}tuples,parse!bang variant,parse/dumpsymmetry
Parsing is internally split into two layers to avoid using exceptions for control flow:
- Validation layer — Each sub-parser (
PiecePlacement,PiecesInHand,StyleTurn) performs all structural validation and data extraction without raising exceptions. This path operates on raw binaries in a single left-to-right pass, without allocating exception objects or capturing backtraces. - Public API layer —
parse/1calls the validation layer internally. On failure, it returns{:error, atom()}.parse!/1callsparse/1and raisesArgumentErrorexactly once, at the boundary.valid?/1callsparse/1and returns a boolean directly, never raising and never constructing aQiobject on invalid input.
This dual-path design eliminates the cost of exception-based control flow on the hot path. Since valid?/1 is commonly called on untrusted or invalid input, avoiding raise/rescue per rejection keeps validation at pure function-call speed.
Serialization follows the same architecture: dump/1 delegates to internal dumpers (Dumper.PiecePlacement, Dumper.PiecesInHand, Dumper.StyleTurn), each producing canonical output for one field.
- Game Protocol — Conceptual foundation
- FEEN Specification — Official specification
- FEEN Examples — Usage examples
- EPIN Specification — Piece token format
- SIN Specification — Style token format
Available as open source under the Apache License 2.0.