Feen.rb
FEEN (Forsyth–Edwards Enhanced Notation) implementation for the Ruby language.
What is FEEN?
FEEN (Forsyth–Edwards Enhanced Notation) is a universal, rule-agnostic notation for representing board game positions. It extends traditional FEN to support:
- Multiple game systems (Chess, Shōgi, Xiangqi, and more)
- Cross-style games where players use different piece sets
- Multi-dimensional boards (2D, 3D, and beyond)
- Captured pieces (pieces-in-hand for drop mechanics)
- Arbitrarily large boards with efficient empty square encoding
- Completely irregular structures (any valid combination of ranks and separators)
- Board-less positions (positions without piece placement, useful for pure style/turn tracking)
This gem implements the FEEN Specification v1.0.0 as a pure functional library with immutable data structures.
Installation
gem "sashite-feen"
Or install manually:
gem install sashite-feen
Quick Start
require "sashite/feen"
# Parse a FEEN string into an immutable position object
position = Sashite::Feen.parse("+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c")
# Access position components
position.placement # Board configuration
position.hands # Captured pieces
position.styles # Game styles and active player
# Convert placement to array based on dimensionality
position.placement.to_a # => [[pieces...], [pieces...], ...] for 2D boards
# Convert back to canonical FEEN string
feen_string = Sashite::Feen.dump(position) # or position.to_s
FEEN Format
A FEEN string consists of three space-separated fields:
<piece-placement> <pieces-in-hand> <style-turn>
Example:
+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c
- Piece placement: Board configuration using EPIN notation with
/separators (can be empty for board-less positions) - Pieces in hand: Captured pieces for each player (format:
first/second) - Style-turn: Game styles and active player (format:
active/inactive)
See the FEEN Specification for complete format details.
API Reference
Module Methods
Sashite::Feen.parse(string)
Parses a FEEN string into an immutable Position object.
- Parameter:
string(String) - FEEN notation string - Returns:
Position- Immutable position object - Raises:
Sashite::Feen::Errorsubclasses on invalid input
position = Sashite::Feen.parse("8/8/8/8/8/8/8/8 / C/c")
# Board-less position (empty piece placement)
position = Sashite::Feen.parse(" / C/c")
Sashite::Feen.dump(position)
Converts a position object into its canonical FEEN string.
- Parameter:
position(Position) - Position object - Returns:
String- Canonical FEEN string - Guarantees: Deterministic output (same position always produces same string)
feen_string = Sashite::Feen.dump(position)
Position Object
The Position object is immutable and provides read-only access to three components:
position.placement # => Placement (board configuration)
position.hands # => Hands (pieces in hand)
position.styles # => Styles (style-turn information)
position.to_s # => String (canonical FEEN)
Equality and hashing:
position1 == position2 # Component-wise equality
position1.hash # Consistent hash for same positions
Placement Object
Represents the board configuration as a flat array of ranks with explicit separators.
placement.ranks # => Array<Array> - Flat array of all ranks
placement.separators # => Array<String> - Separators between ranks (e.g., ["/", "//"])
placement.dimension # => Integer - Board dimensionality (1 + max consecutive slashes)
placement.rank_count # => Integer - Total number of ranks
placement.one_dimensional? # => Boolean - True if dimension is 1
placement.all_pieces # => Array - All pieces (nils excluded)
placement.total_squares # => Integer - Total square count
placement.to_s # => String - Piece placement field
placement.to_a # => Array - Array representation (dimension-aware)
to_a - Dimension-Aware Array Conversion
The to_a method returns an array representation that adapts to the board's dimensionality:
- 1D boards: Returns a single rank array (or empty array if no ranks)
- 2D+ boards: Returns array of ranks
# 1D board - Returns flat array
feen = "K2P3k / C/c"
position = Sashite::Feen.parse(feen)
position.placement.to_a
# => [K, nil, nil, P, nil, nil, nil, k]
# 2D board - Returns array of arrays
feen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / C/c"
position = Sashite::Feen.parse(feen)
position.placement.to_a
# => [[r,n,b,q,k,b,n,r], [p,p,p,p,p,p,p,p], [nil×8], ...]
# 3D board - Returns array of ranks (to be structured by application)
feen = "5/5//5/5 / R/r"
position = Sashite::Feen.parse(feen)
position.placement.to_a
# => [[nil×5], [nil×5], [nil×5], [nil×5]]
# Empty board
placement = Sashite::Feen::Placement.new([], [], 1)
placement.to_a
# => []
Other methods:
# Access specific positions
first_rank = placement.ranks[0]
piece_at_a1 = first_rank[0] # Piece object or nil
# Check dimensionality
placement.dimension # => 2 (2D board)
# Inspect separator structure
placement.separators # => ["/", "/", "/", "/", "/", "/", "/"]
Hands Object
Represents captured pieces held by each player.
hands.first_player # => Array - Pieces held by first player
hands.second_player # => Array - Pieces held by second player
hands.empty? # => Boolean - True if both hands are empty
hands.to_s # => String - Pieces-in-hand field
Example:
# Count pieces in hand
first_player_pawns = hands.first_player.count { |p| p.to_s == "P" }
# Check if any captures
hands.empty? # => false
Styles Object
Represents game styles and indicates the active player.
styles.active # => SIN identifier - Active player's style
styles.inactive # => SIN identifier - Inactive player's style
styles.to_s # => String - Style-turn field
Example:
# Determine active player
styles.active.to_s # => "C" (first player Chess)
styles.inactive.to_s # => "c" (second player Chess)
# Check if cross-style
styles.active.to_s.upcase != styles.inactive.to_s.upcase
Examples
Chess Positions
# Starting position
chess_start = Sashite::Feen.parse(
"+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c"
)
# After 1.e4
after_e4 = Sashite::Feen.parse(
"+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/4P3/8/+P+P+P+P1+P+P+P/+RNBQ+KBN+R / c/C"
)
# Ruy Lopez opening
ruy_lopez = Sashite::Feen.parse(
"r1bqkbnr/+p+p+p+p1+p+p+p/2n5/1B2p3/4P3/5N2/+P+P+P+P1+P+P+P/RNBQK2R / c/C"
)
Shōgi with Captured Pieces
# Starting position
shogi_start = Sashite::Feen.parse(
"lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL / S/s"
)
# Position with pieces in hand
shogi_midgame = Sashite::Feen.parse(
"lnsgkgsnl/1r5b1/pppp1pppp/9/4p4/9/PPPP1PPPP/1B5R1/LNSGKGSNL P/p s/S"
)
# Access captured pieces
position = shogi_midgame
position.hands.first_player # => [P] (one pawn)
position.hands.second_player # => [p] (one pawn)
# Count specific pieces in hand
position.hands.first_player.count { |p| p.to_s == "P" } # => 1
Cross-Style Games
# Chess vs Makruk
chess_vs_makruk = Sashite::Feen.parse(
"rnsmksnr/8/pppppppp/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/m"
)
# Chess vs Shōgi
chess_vs_shogi = Sashite::Feen.parse(
"lnsgkgsnl/1r5b1/pppppppp/9/9/9/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/s"
)
# Check styles
position = chess_vs_makruk
position.styles.active.to_s # => "C" (Chess, first player)
position.styles.inactive.to_s # => "m" (Makruk, second player)
Multi-Dimensional Boards
# 3D Chess (Raumschach)
raumschach = Sashite::Feen.parse(
"rnknr/+p+p+p+p+p/5/5/5//buqbu/+p+p+p+p+p/5/5/5//5/5/5/5/5//5/5/5/+P+P+P+P+P/BUQBU//5/5/5/+P+P+P+P+P/RNKNR / R/r"
)
# Check dimensionality
raumschach.placement.dimension # => 3 (3D board)
raumschach.placement.ranks.size # => 25 (total ranks)
# Inspect separator structure
level_seps = raumschach.placement.separators.count { |s| s == "//" }
rank_seps = raumschach.placement.separators.count { |s| s == "/" }
# level_seps => 4 (separates 5 levels)
# rank_seps => 20 (separates ranks within levels)
Irregular Boards
# Diamond-shaped board
diamond = Sashite::Feen.parse("3/4/5/4/3 / G/g")
# Check structure
diamond.placement.ranks.map(&:size) # => [3, 4, 5, 4, 3]
# Very large board
large_board = Sashite::Feen.parse("100/100/100 / G/g")
large_board.placement.total_squares # => 300
# Single square
single = Sashite::Feen.parse("K / C/c")
single.placement.rank_count # => 1
Completely Irregular Structures
FEEN supports any valid combination of ranks and separators:
# Extreme irregularity with variable separators
feen = "99999/3///K/k//r / G/g"
position = Sashite::Feen.parse(feen)
# Access the structure
position.placement.ranks.size # => 5 ranks
position.placement.separators # => ["/", "///", "/", "//"]
position.placement.dimension # => 4 (max separator is "///")
# Each rank can have different sizes
position.placement.ranks[0].size # => 99999
position.placement.ranks[1].size # => 3
position.placement.ranks[2].size # => 1
position.placement.ranks[3].size # => 1
position.placement.ranks[4].size # => 1
# Round-trip preservation
Sashite::Feen.dump(position) == feen # => true
Empty Ranks
FEEN supports empty ranks (ranks with no pieces):
# Trailing separator creates empty rank
feen = "K/// / C/c"
position = Sashite::Feen.parse(feen)
position.placement.ranks.size # => 2
position.placement.ranks[0] # => [K]
position.placement.ranks[1] # => [] (empty rank)
position.placement.separators # => ["///"]
# Round-trip preserves structure
Sashite::Feen.dump(position) == feen # => true
Board-less Positions
FEEN supports positions without piece placement, useful for tracking only style and turn information:
# Position with empty board (no piece placement)
board_less = Sashite::Feen.parse(" / C/c")
board_less.placement.ranks.size # => 1
board_less.placement.dimension # => 1
board_less.placement.to_a # => []
# Convert back to FEEN
Sashite::Feen.dump(board_less) # => " / C/c"
Working with Positions
# Compare positions
position1 = Sashite::Feen.parse("8/8/8/8/8/8/8/8 / C/c")
position2 = Sashite::Feen.parse("8/8/8/8/8/8/8/8 / C/c")
position1 == position2 # => true
# Round-trip parsing
original = "+rnbq+kbn+r/+p+p+p+p+p+p+p+p/8/8/8/8/+P+P+P+P+P+P+P+P/+RNBQ+KBN+R / C/c"
position = Sashite::Feen.parse(original)
Sashite::Feen.dump(position) == original # => true
# Extract specific information
position.placement.ranks[0] # First rank (array of pieces/nils)
position.hands.first_player.size # Number of captured pieces
State Modifiers and Derivation
# Enhanced pieces (promoted, with special rights)
enhanced = Sashite::Feen.parse("+K+Q+R+B/8/8/8/8/8/8/8 / C/c")
# Diminished pieces (weakened, vulnerable)
diminished = Sashite::Feen.parse("-K-Q-R-B/8/8/8/8/8/8/8 / C/c")
# Foreign pieces (using opponent's style)
foreign = Sashite::Feen.parse("K'Q'R'B'/k'q'r'b'/8/8/8/8/8/8 / C/s")
Error Handling
FEEN defines specific error classes for different validation failures:
begin
position = Sashite::Feen.parse("invalid feen")
rescue Sashite::Feen::Error => e
# Base error class catches all FEEN errors
warn "FEEN error: #{e.}"
end
Error Hierarchy
Sashite::Feen::Error # Base error class
├── Error::Syntax # Malformed FEEN structure
├── Error::Piece # Invalid EPIN notation
├── Error::Style # Invalid SIN notation
├── Error::Count # Invalid piece counts
└── Error::Validation # Other semantic violations
Common Errors
# Syntax error - wrong field count
Sashite::Feen.parse("8/8/8/8/8/8/8/8 /")
# => Error::Syntax: "FEEN must have exactly 3 space-separated fields, got 2"
# Style error - invalid SIN
Sashite::Feen.parse("8/8/8/8/8/8/8/8 / 1/2")
# => Error::Style: "failed to parse SIN '1': invalid SIN notation: '1' (must be a single letter A-Z or a-z)"
# Count error - invalid quantity
Sashite::Feen.parse("8/8/8/8/8/8/8/8 0P/ C/c")
# => Error::Count: "piece count must be at least 1, got 0"
Properties
- Purely functional: Immutable data structures, no side effects
- Canonical output: Deterministic string generation (same position → same string)
- Specification compliant: Strict adherence to FEEN v1.0.0
- Minimal API: Two methods (
parseanddump) for complete functionality - Universal: Supports any abstract strategy board game
- Completely flexible: Accepts any valid combination of ranks and separators
- Perfect round-trip:
parse(dump(position)) == positionguaranteed - Dimension-aware: Intelligent array conversion based on board structure
- Composable: Built on EPIN and SIN specifications
Dependencies
- sashite-epin — Extended Piece Identifier Notation
- sashite-sin — Style Identifier Notation
Documentation
- FEEN Specification v1.0.0 — Complete technical specification
- FEEN Examples — Comprehensive examples
- API Documentation — Full API reference
- GitHub Wiki — Advanced usage and patterns
Development
# Clone the repository
git clone https://github.com/sashite/feen.rb.git
cd feen.rb
# Install dependencies
bundle install
# Run tests
ruby test.rb
# Generate documentation
yard doc
Contributing
- Fork the repository
- Create a feature branch (
git checkout -b feature/new-feature) - Add tests for your changes
- Ensure all tests pass (
ruby test.rb) - Commit your changes (
git commit -am 'Add new feature') - Push to the branch (
git push origin feature/new-feature) - Create a Pull Request
License
Available as open source under the MIT License.
About
Maintained by Sashité — promoting chess variants and sharing the beauty of board game cultures.