PMN (Portable Move Notation) implementation for the Ruby language.
PMN (Portable Move Notation) is a rule-agnostic, array-based format for describing the mechanical decomposition of moves in abstract strategy board games. PMN breaks down complex movements into sequences of atomic actions, revealing the underlying mechanics while remaining completely independent of specific game rules, validation logic, or gameplay concepts.
This gem implements the PMN Specification v1.0.0, providing a small, functional Ruby interface for working with mechanical move decomposition across any board game system.
# In your Gemfile
gem "sashite-pmn"Or install manually:
gem install sashite-pmnPMN builds upon three foundational Sashité specifications:
gem "sashite-cell" # Multi-dimensional coordinate encoding
gem "sashite-hand" # Reserve location notation
gem "sashite-qpi" # Qualified Piece Identifierrequire "sashite/pmn"
# Parse PMN arrays into move objects
move = Sashite::Pmn.parse(["e2", "e4", "C:P"])
move.valid? # => true
move.actions # => [#<Sashite::Pmn::Action ...>]
move.to_a # => ["e2", "e4", "C:P"]
# Validate PMN arrays
Sashite::Pmn.valid?(["e2", "e4", "C:P"]) # => true
Sashite::Pmn.valid?(%w[e2 e4]) # => true (inferred piece)
Sashite::Pmn.valid?([]) # => true (pass move)
Sashite::Pmn.valid?(["e2"]) # => false (incomplete)
# Create moves programmatically
move = Sashite::Pmn.from_actions([
Sashite::Pmn::Action.new("e2", "e4", "C:P")
])A pass move is represented by an empty array [], allowing a player to voluntarily conclude their turn without performing any displacement or mutation.
# Parse a pass move
pass = Sashite::Pmn.parse([])
pass.valid? # => true
pass.pass? # => true
pass.empty? # => true
pass.actions # => []
pass.to_a # => []
# Create a pass move directly
pass = Sashite::Pmn::Move.new
pass.pass? # => true
# Validate pass moves
Sashite::Pmn.valid?([]) # => trueImportant: According to the Sashité Protocol, a pass move that results in a position identical to a previous position violates the uniqueness constraint. Game engines must enforce this constraint.
# Simple move with explicit piece
move = Sashite::Pmn.parse(["e2", "e4", "C:P"])
action = move.actions.first
action.source # => "e2"
action.destination # => "e4"
action.piece # => "C:P"
action.piece_specified? # => true
# Move with inferred piece
move = Sashite::Pmn.parse(%w[e2 e4])
action = move.actions.first
action.piece # => nil
action.piece_specified? # => false
action.inferred? # => true
# In-place transformations (source == destination)
transform = Sashite::Pmn.parse(["e4", "e4", "C:+P"])
transform.valid? # => true
action = transform.actions.first
action.in_place? # => true
action.transformation? # => true
# Reserve operations
drop = Sashite::Pmn.parse(["*", "e5", "S:P"]) # Drop from reserve
capture = Sashite::Pmn.parse(["e4", "*"]) # Capture to reserve (inferred piece)# Multi-action move (castling)
castling = Sashite::Pmn.parse([
"e1", "g1", "C:K",
"h1", "f1", "C:R"
])
castling.compound? # => true
castling.simple? # => false
castling.pass? # => false
castling.actions.size # => 2
# En passant
en_passant = Sashite::Pmn.parse([
"e5", "f6", "C:P",
"f5", "*", "c:p"
])
en_passant.has_captures? # => true
en_passant.board_moves.size # => 1move = Sashite::Pmn.parse([
"e1", "g1", "C:K",
"h1", "f1", "C:R"
])
# Structure analysis
move.pass? # => false
move.simple? # => false
move.compound? # => true
move.size # => 2
move.empty? # => false
# Drop/capture checks
move.has_drops? # => false
move.has_captures? # => false
move.board_moves.size # => 2
# Extract information
move.sources # => ["e1", "h1"]
move.destinations # => ["g1", "f1"]
move.pieces # => ["C:K", "C:R"]
move.has_inferred? # => falseaction = move.actions.first
# Location predicates
action.board_to_board? # => true
action.from_reserve? # => false
action.to_reserve? # => false
action.drop? # => false
action.capture? # => false
action.board_move? # => true
action.in_place? # => false (source != destination)
action.transformation? # => false (not in-place with state change)
# Validation predicates
action.valid? # => true
action.piece_valid? # => true or false depending on piece# Invalid action built directly raises action-level errors
begin
Sashite::Pmn::Action.new("invalid", "e4", "C:P")
rescue Sashite::Pmn::Error::Location => e
puts e.message
end
begin
Sashite::Pmn::Action.new("e2", "e4", "InvalidPiece")
rescue Sashite::Pmn::Error::Piece => e
puts e.message
end
# Parsing a move wraps action-level errors as Error::Move
begin
Sashite::Pmn.parse(["e2"]) # Incomplete action
rescue Sashite::Pmn::Error::Move => e
puts e.message
end
# Pass moves are always valid structurally
Sashite::Pmn.parse([]).valid? # => true (never raises)Sashite::Pmn.parse(array)— Parse a PMN array into aMoveobject. Accepts empty arrays[]for pass moves.Sashite::Pmn.valid?(array)— Check if an array is valid PMN notation (non-raising). Returnstruefor[].Sashite::Pmn.from_actions(actions)— Build aMovefromActionobjects. Pass empty array for pass moves.Sashite::Pmn.valid_location?(location)— Check if a location is valid (CELL or"*").Sashite::Pmn.valid_piece?(piece)— Check if a piece is valid QPI.
Sashite::Pmn::Move.new(*elements)— Create from PMN elements (variadic). Call with no arguments for pass move.Move.new("e2", "e4", "C:P")— Standard moveMove.new— Pass move (empty)- Note:
Move.new(["e2","e4","C:P"])is not accepted; pass individual arguments.
Sashite::Pmn::Move.from_actions(actions)— Create fromActionobjects. Pass[]for pass move.
#valid?— Check overall validity.#actions— Ordered array ofActionobjects (frozen). Empty for pass moves.#pmn_array— Original PMN elements (frozen). Empty for pass moves.#to_a— Copy of the PMN elements. Returns[]for pass moves.
#size/#length— Number of actions. Returns0for pass moves.#empty?— No actions? Returnstruefor pass moves.#pass?— Is this a pass move? Returnstrueonly for empty moves.#simple?— Exactly one action? Returnsfalsefor pass moves.#compound?— Multiple actions? Returnsfalsefor pass moves.#first_action/#last_action— Convenience accessors. Returnnilfor pass moves.#has_drops?/#has_captures?— Presence of drops/captures. Returnfalsefor pass moves.#board_moves— Actions that are board-to-board. Returns[]for pass moves.#sources/#destinations/#pieces— Unique lists. Return[]for pass moves.#has_inferred?— Any action with inferred piece? Returnsfalsefor pass moves.
Sashite::Pmn::Action.new(source, destination, piece = nil)
#source,#destination,#piece#to_a—["src", "dst"]or["src", "dst", "piece"]#to_h—{ source:, destination:, piece: }(piece omitted ifnil)
#inferred?,#piece_specified?,#piece_valid?#from_reserve?,#to_reserve?#reserve_to_board?(drop),#board_to_reserve?(capture),#board_to_board?#drop?(alias),#capture?(alias),#board_move?#in_place?— Source equals destination?#transformation?— In-place with state change?#valid?
Sashite::Pmn::Error— Base error classSashite::Pmn::Error::Move— Invalid PMN sequence / parsing failureSashite::Pmn::Error::Action— Invalid atomic actionSashite::Pmn::Error::Location— Invalid location (not CELL or HAND)Sashite::Pmn::Error::Piece— Invalid piece (not QPI format)
PMN moves are flat arrays containing action sequences or empty for pass moves:
[] # Pass move
[<element-1>, <element-2>, <element-3>, ...] # Action sequence
A pass move is represented by an empty array:
[]This indicates that the active player concludes their turn without performing any action. The resulting position must respect the position uniqueness constraint defined in the Sashité Protocol.
Each action consists of 2 or 3 consecutive elements:
[<source>, <destination>, <piece>?]
- Source: CELL coordinate or
"*"(reserve) - Destination: CELL coordinate or
"*"(reserve) - Piece: QPI string (optional; may be inferred)
- Pass move: 0 elements (empty array)
- Action moves: Minimum 2 elements (one action with inferred piece)
- Valid non-empty lengths: multiple of 3, or multiple of 3 plus 2
Actions where source == destination are allowed, enabling:
- In-place transformations with state change (e.g.,
["e4", "e4", "C:+P"]) - Context-dependent mutations specified by game rules
Important: Actions without observable effect (same location, same piece state) should be avoided. Use pass moves [] instead for turn-only actions.
# Player passes their turn
pass = Sashite::Pmn.parse([])
pass.pass? # => true
# In games where passing is strategic (e.g., Go, some variants)
game_engine.execute_move([]) # Pass turn to opponent# Pawn move
pawn_move = Sashite::Pmn.parse(["e2", "e4", "C:P"])
# Castling kingside
castling = Sashite::Pmn.parse([
"e1", "g1", "C:K",
"h1", "f1", "C:R"
])
# En passant
en_passant = Sashite::Pmn.parse([
"e5", "f6", "C:P",
"f5", "*", "c:p"
])
# Promotion
promotion = Sashite::Pmn.parse(["e7", "e8", "C:Q"])
# In-place promotion (variant rule)
in_place_promotion = Sashite::Pmn.parse(["e8", "e8", "C:Q"])# Drop piece from hand
drop = Sashite::Pmn.parse(["*", "e5", "S:P"])
# Capture and convert
capture = Sashite::Pmn.parse([
"a1", "*", "S:L",
"b2", "a1", "S:S"
])
# Promotion
promotion = Sashite::Pmn.parse(["h8", "i8", "S:+S"])
# Pass (uncommon in Shōgi but structurally valid)
pass = Sashite::Pmn.parse([])# General move
general_move = Sashite::Pmn.parse(["e1", "e2", "X:G"])
# Cannon capture (jumping)
cannon_capture = Sashite::Pmn.parse([
"b3", "*", "x:s",
"b1", "b9", "X:C"
])# Place stone
place_stone = Sashite::Pmn.parse(["*", "d4", "G:B"])
# Pass (very common in Go)
pass = Sashite::Pmn.parse([])
pass.pass? # => true
# Consecutive passes typically end the game in Go
end_game_and_score if last_move.pass? && current_move.pass?actions = []
actions << Sashite::Pmn::Action.new("e2", "e4", "C:P")
actions << Sashite::Pmn::Action.new("d7", "d5", "c:p")
move = Sashite::Pmn.from_actions(actions)
move.to_a # => ["e2", "e4", "C:P", "d7", "d5", "c:p"]
# Create pass move
pass = Sashite::Pmn.from_actions([])
pass.pass? # => trueclass GameEngine
def execute_move(pmn_array)
move = Sashite::Pmn.parse(pmn_array)
# Handle pass moves
if move.pass?
handle_pass_move
switch_active_player
return
end
# Execute each action
move.actions.each do |action|
if action.from_reserve?
place_piece(action.destination, action.piece)
elsif action.to_reserve?
capture_piece(action.source)
elsif action.in_place?
transform_piece(action.source, action.piece)
else
move_piece(action.source, action.destination, action.piece)
end
end
switch_active_player
end
private
def handle_pass_move
# Check position uniqueness constraint
raise "Pass move creates repeated position (protocol violation)" if position_seen_before?(current_position)
# Record pass in game history
record_move([])
end
# ...
endclass PositionTracker
def initialize
@seen_positions = Set.new
@position_history = []
end
def record_move(pmn_array, resulting_position)
move = Sashite::Pmn.parse(pmn_array)
# For pass moves, verify uniqueness constraint
if move.pass? && @seen_positions.include?(resulting_position)
raise "Pass move violates position uniqueness constraint"
end
@seen_positions.add(resulting_position)
@position_history << { move: pmn_array, position: resulting_position }
end
def position_seen?(position)
@seen_positions.include?(position)
end
end- Rule-agnostic: Independent of specific game mechanics
- Mechanical decomposition: Breaks complex moves into atomic actions
- Array-based: Simple, interoperable structure
- Pass move support: Empty arrays
[]for voluntary turn conclusion - Sequential execution: Actions execute in array order
- Piece inference: Optional piece specification when context is clear
- Universal applicability: Works across board game systems
- Functional design: Immutable data structures
- Dependency integration: CELL, HAND, and QPI specs
Pass moves ([]) indicate:
- No piece displacement occurs
- No piece mutation occurs
- The active player voluntarily concludes their turn
- The resulting position must be unique according to protocol constraints
For non-pass moves, each action applies atomically:
-
Source state change:
- CELL → becomes empty
- HAND
"*"→ remove piece from reserve
-
Destination state change:
- CELL → contains final piece
- HAND
"*"→ add piece to reserve
-
Piece transformation: Final state (specified or inferred)
-
Atomic commitment: Each action applies atomically
According to the Sashité Protocol, all positions within a match must be unique. This applies to pass moves:
- A pass move that results in a position identical to any previous position violates the constraint
- Game engines must track position history and enforce this rule
- However, if the position after a pass move is unique (due to time-sensitive state or other factors), the pass is valid
PMN supports all protocol-authorized operations:
- Board-to-board: Standard piece movement
- Board-to-hand: Captures to reserve
- Hand-to-board: Drops from reserve
- In-place transformations: State changes without displacement
- Pass moves: Voluntary turn conclusion
Prohibited: Hand-to-hand transfers are not allowed by the protocol.
Available as open source under the MIT License.
Bug reports and pull requests are welcome on GitHub at https://github.com/sashite/pmn.rb.
- PMN Specification v1.0.0
- PMN Examples
- CELL Specification
- HAND Specification
- QPI Specification
- Sashité Protocol
- Glossary
Maintained by Sashité — promoting chess variants and sharing the beauty of board game cultures.