0.0
No release in over 3 years
FEEN (Forsyth—Edwards Enhanced Notation) provides a universal, rule-agnostic format for representing board game positions. This gem implements the FEEN Specification v1.0.0 with a modern Ruby interface featuring immutable position objects and functional programming principles. FEEN extends traditional FEN notation to support multiple game systems (chess, shōgi, xiangqi, makruk), cross-style games, multi-dimensional boards, and captured pieces held in reserve. Built on EPIN (piece notation) and SIN (style notation) foundations, FEEN enables canonical position representation across diverse abstract strategy board games. Perfect for game engines, position analysis tools, and hybrid gaming systems requiring comprehensive board state representation.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies

Runtime

 Project Readme

Feen.rb

Version Yard documentation Ruby License

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
  1. Piece placement: Board configuration using EPIN notation with / separators (can be empty for board-less positions)
  2. Pieces in hand: Captured pieces for each player (format: first/second)
  3. 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::Error subclasses 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.message}"
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 (parse and dump) 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)) == position guaranteed
  • Dimension-aware: Intelligent array conversion based on board structure
  • Composable: Built on EPIN and SIN specifications

Dependencies

Documentation

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

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/new-feature)
  3. Add tests for your changes
  4. Ensure all tests pass (ruby test.rb)
  5. Commit your changes (git commit -am 'Add new feature')
  6. Push to the branch (git push origin feature/new-feature)
  7. 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.