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.