GGN (General Gameplay Notation) implementation for Ruby — evaluates movement possibilities in abstract strategy board games.
GGN (General Gameplay Notation) is a rule-agnostic format for describing pseudo-legal moves in abstract strategy board games. GGN serves as a movement possibility oracle: given a piece at a source location and a desired destination, it determines if the movement is feasible based on environmental pre-conditions.
This gem implements the GGN Specification v1.0.0.
# In your Gemfile
gem "sashite-ggn"Or install manually:
gem install sashite-ggnrequire "sashite/ggn"
# Define GGN data structure
ggn_data = {
"C:P" => { # Chess pawn
"e2" => { # From e2
"e4" => [ # To e4
{
"must" => { # Required conditions
"e3" => "empty",
"e4" => "empty"
},
"deny" => {} # Forbidden conditions
}
]
}
}
}
# Parse into ruleset
ruleset = Sashite::Ggn.parse(ggn_data)
# Query movement through method chaining
active_side = :first
squares = { "e2" => "C:P", "e3" => nil, "e4" => nil }
possibilities = ruleset
.select("C:P") # Select piece type
.from("e2") # From source location
.to("e4") # To destination location
.where(active_side, squares) # Evaluate conditions
possibilities.any? # => true (movement is possible)GGN uses a hierarchical structure that naturally maps to method chaining:
Piece → Source → Destination → Possibilities
Each level provides introspection methods to explore available options:
# Explore available pieces
ruleset.pieces # => ["C:K", "C:Q", "C:P", ...]
# Explore sources for a piece
ruleset.select("C:P").sources # => ["a2", "b2", "c2", ...]
# Explore destinations from a source
ruleset.select("C:P").from("e2").destinations # => ["e3", "e4"]
# Check existence at any level
ruleset.piece?("C:K") # => true
ruleset.select("C:K").source?("e1") # => true
ruleset.select("C:K").from("e1").destination?("e2") # => trueThe where method evaluates movement possibilities against the current board state:
# Returns array of matching possibilities (may be empty)
possibilities = engine.where(active_side, squares)
# Each possibility is a Hash containing the original GGN data
# that satisfied the conditions
possibility = possibilities.first
# => { "must" => {...}, "deny" => {...} }Key points:
active_side(Symbol)::firstor:second- determines enemy evaluationsquares(Hash): Board state where keys are CELL coordinates, values are QPI identifiers ornil- Returns an array of possibilities that match the conditions
# Parse GGN data into a ruleset
ruleset = Sashite::Ggn.parse(data)
# Validate GGN data structure
Sashite::Ggn.valid?(data) # => true/false# Select piece movement rules
source = ruleset.select("C:K")
# Check if piece exists
ruleset.piece?("C:K") # => true/false
# List all pieces
ruleset.pieces # => ["C:K", "C:Q", ...]# Select source location
destination = source.from("e1")
# Check if source exists
source.source?("e1") # => true/false
# List all sources
source.sources # => ["e1", "d1", ...]# Select destination location
engine = destination.to("e2")
# Check if destination exists
destination.destination?("e2") # => true/false
# List all destinations
destination.destinations # => ["d1", "d2", ...]# Evaluate movement possibilities
possibilities = engine.where(active_side, squares)
# Returns array of possibility hashes that match conditions# Two-square advance from starting position
ggn_data = {
"C:P" => {
"e2" => {
"e4" => [{
"must" => { "e3" => "empty", "e4" => "empty" },
"deny" => {}
}]
}
}
}
ruleset = Sashite::Ggn.parse(ggn_data)
# Valid: path is clear
squares = { "e2" => "C:P", "e3" => nil, "e4" => nil }
possibilities = ruleset.select("C:P").from("e2").to("e4").where(:first, squares)
possibilities.any? # => true
# Invalid: e3 is blocked
squares = { "e2" => "C:P", "e3" => "c:p", "e4" => nil }
possibilities = ruleset.select("C:P").from("e2").to("e4").where(:first, squares)
possibilities.any? # => false# Diagonal capture
ggn_data = {
"C:P" => {
"e4" => {
"d5" => [{
"must" => { "d5" => "enemy" },
"deny" => {}
}]
}
}
}
ruleset = Sashite::Ggn.parse(ggn_data)
# Valid: enemy piece on d5
squares = { "e4" => "C:P", "d5" => "c:p" }
possibilities = ruleset.select("C:P").from("e4").to("d5").where(:first, squares)
possibilities.any? # => true
# Invalid: friendly piece on d5
squares = { "e4" => "C:P", "d5" => "C:N" }
possibilities = ruleset.select("C:P").from("e4").to("d5").where(:first, squares)
possibilities.any? # => false# King-side castling
ggn_data = {
"C:K" => {
"e1" => {
"g1" => [{
"must" => {
"f1" => "empty",
"g1" => "empty",
"h1" => "C:+R" # Rook with castling rights
},
"deny" => {}
}]
}
}
}
ruleset = Sashite::Ggn.parse(ggn_data)
# Valid: all conditions met
squares = {
"e1" => "C:+K",
"f1" => nil,
"g1" => nil,
"h1" => "C:+R"
}
possibilities = ruleset.select("C:K").from("e1").to("g1").where(:first, squares)
possibilities.any? # => true# Pawn drop with file restriction
ggn_data = {
"S:P" => {
"*" => { # From hand
"e4" => [{
"must" => { "e4" => "empty" },
"deny" => { # No friendly pawn on same file
"e1" => "S:P", "e2" => "S:P", "e3" => "S:P",
"e5" => "S:P", "e6" => "S:P", "e7" => "S:P",
"e8" => "S:P", "e9" => "S:P"
}
}]
}
}
}
ruleset = Sashite::Ggn.parse(ggn_data)
# Valid: no pawn on e-file
squares = {
"e1" => nil, "e2" => nil, "e3" => nil, "e4" => nil,
"e5" => nil, "e6" => nil, "e7" => nil, "e8" => nil, "e9" => nil
}
possibilities = ruleset.select("S:P").from("*").to("e4").where(:first, squares)
possibilities.any? # => true
# Invalid: pawn already on e5
squares["e5"] = "S:P"
possibilities = ruleset.select("S:P").from("*").to("e4").where(:first, squares)
possibilities.any? # => false# En passant capture
ggn_data = {
"C:P" => {
"e5" => {
"f6" => [{
"must" => {
"f6" => "empty",
"f5" => "c:-p" # Enemy pawn vulnerable to en passant
},
"deny" => {}
}]
}
}
}
ruleset = Sashite::Ggn.parse(ggn_data)
squares = {
"e5" => "C:P",
"f5" => "c:-p",
"f6" => nil
}
possibilities = ruleset.select("C:P").from("e5").to("f6").where(:first, squares)
possibilities.any? # => true# Missing piece
begin
ruleset.select("X:Y")
rescue KeyError => e
puts e.message # => "Piece not found: X:Y"
end
# Missing source
begin
ruleset.select("C:K").from("z9")
rescue KeyError => e
puts e.message # => "Source not found: z9"
end
# Invalid GGN data
begin
Sashite::Ggn.parse({ "invalid" => "data" })
rescue ArgumentError => e
puts e.message # => "Invalid QPI format: invalid"
end
# Safe validation
if Sashite::Ggn.valid?(data)
ruleset = Sashite::Ggn.parse(data)
else
puts "Invalid GGN structure"
endDirect movements from hand to hand (source="*" and destination="*") are forbidden by the specification:
# This will raise an error
invalid_ggn = {
"S:P" => {
"*" => {
"*" => [{ "must" => {}, "deny" => {} }] # FORBIDDEN!
}
}
}
Sashite::Ggn.valid?(invalid_ggn) # => false
Sashite::Ggn.parse(invalid_ggn) # => ArgumentErrorThis gem depends on other Sashité specifications:
sashite-cell- Coordinate encoding (e.g.,"e4")sashite-hand- Reserve notation ("*")sashite-lcn- Location conditions (e.g.,"empty","enemy")sashite-qpi- Piece identification (e.g.,"C:K")
Available as open source under the MIT License.
Maintained by Sashité — promoting chess variants and sharing the beauty of board game cultures.