0.0
No release in over 3 years
A Ruby implementation of the General Gameplay Notation (GGN) specification. GGN is a rule-agnostic, JSON-based format for describing pseudo-legal board-to-board transformations in abstract strategy board games. This library provides parsing, validation, and evaluation capabilities for GGN documents, focusing exclusively on piece movements, captures, and transformations on the game board. Features flexible validation system for both safety and performance. Supports Chess, Shogi, Xiangqi, and custom variants without hand management or piece drops.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies

Runtime

~> 2.4.0
 Project Readme

Ggn.rb

Version Yard documentation Ruby License

GGN (General Gameplay Notation) implementation for the Ruby language — a pure, functional library for evaluating movement possibilities in abstract strategy board games.

What is GGN?

GGN (General Gameplay Notation) is a rule-agnostic format that describes pseudo-legal moves in board games. It answers: "Can this piece, currently at this location, reach that location?" — while remaining agnostic about game-specific rules like check, ko, or repetition.

GGN serves as a movement possibility oracle that encodes:

  • Which piece can move
  • From which location
  • To which location
  • Under what pre-conditions (must and deny)
  • What state changes occur (diff in STN format)

This gem implements the GGN Specification v1.0.0, providing a pure functional API for working with movement possibilities.

Installation

# In your Gemfile
gem "sashite-ggn"

Or install manually:

gem install sashite-ggn

Dependencies

GGN builds upon the Sashité specification ecosystem:

Quick Start

require "sashite/ggn"

# GGN data as a Ruby hash (loaded externally)
ggn_data = {
  "C:P" => {
    "e2" => {
      "e4" => [
        {
          "must" => { "e3" => "empty", "e4" => "empty" },
          "deny" => {},
          "diff" => {
            "board" => { "e2" => nil, "e4" => "C:P" },
            "toggle" => true
          }
        }
      ]
    }
  }
}

# Create a ruleset from the GGN data
ruleset = Sashite::Ggn.parse(ggn_data)

# Navigate through the movement hierarchy
engine = ruleset.select("C:P").from("e2").to("e4")

# Evaluate the move
board_state = { "e2" => "C:P", "e3" => nil, "e4" => nil }
transitions = engine.where(board_state)

if transitions.any?
  puts "Move is possible!"
  transitions.each { |t| puts "State changes: #{t}" }
end

API Reference

Main Module

Sashite::Ggn.parse(data) → Ruleset

Creates a Ruleset from GGN data hash.

ruleset = Sashite::Ggn.parse(ggn_data)

Sashite::Ggn.valid?(data) → Boolean

Validates GGN data structure against the specification.

Sashite::Ggn.valid?(ggn_data) # => true

Navigation Chain

The API follows GGN's hierarchical structure through method chaining:

ruleset.select(piece)    # → Source
       .from(origin)     # → Destination
       .to(target)       # → Engine
       .where(board)     # → Array<Transition>

Ruleset Class

The entry point for querying movement rules.

#select(piece) → Source

Selects movement rules for a specific piece type.

source = ruleset.select("C:K")  # Chess king
source = ruleset.select("s:+p") # Shogi promoted pawn (gote)

#pseudo_legal_transitions(board_state) → Array

Generates all possible moves for the current position.

all_moves = ruleset.pseudo_legal_transitions(board_state)
# => [["C:K", "e1", "e2", [...]], ["C:Q", "d1", "d4", [...]], ...]

all_moves.each do |piece, from, to, transitions|
  puts "#{piece}: #{from}#{to} (#{transitions.size} variants)"
end

Source Class

Represents possible source positions for a piece type.

#from(origin) → Destination

Gets possible destinations from a source position.

destinations = source.from("e1")

Destination Class

Represents possible destination squares from a source.

#to(target) → Engine

Creates an engine for evaluating a specific move.

engine = destinations.to("e2")

Engine Class

Evaluates move validity under given board conditions.

#where(board_state) → Array<Transition>

Returns valid transitions for the current board state.

board = { "e1" => "C:K", "e2" => nil }
transitions = engine.where(board)
# => [#<Sashite::Stn::Transition ...>]

Transition Class (from sashite-stn)

Represents state changes from a move.

transition = transitions.first
transition.board_changes  # => { "e1" => nil, "e2" => "C:K" }
transition.hand_changes   # => {}
transition.toggle?        # => true

GGN Format

Structure

{
  "<qpi-piece>" => {
    "<source-location>" => {
      "<destination-location>" => [
        {
          "must" => { "<location>" => "<state>", ... },
          "deny" => { "<location>" => "<state>", ... },
          "diff" => { /* STN format */ }
        }
      ]
    }
  }
}

Core Components

Field Type Description
Piece QPI string Piece identifier (e.g., "C:K", "s:+p")
Source CELL or "*" Origin location
Destination CELL or "*" Target location
must Hash Pre-conditions that must be satisfied (AND logic)
deny Hash Pre-conditions that must not be satisfied (OR logic)
diff Hash State transition in STN format

Location States

State Meaning
"empty" Location must be empty
"enemy" Location must contain an opposing piece
QPI string Location must contain exactly this piece

Usage Examples

Simple Movement

# King moves one square
ggn_data = {
  "C:K" => {
    "e1" => {
      "e2" => [
        {
          "must" => { "e2" => "empty" },
          "deny" => {},
          "diff" => {
            "board" => { "e1" => nil, "e2" => "C:K" },
            "toggle" => true
          }
        }
      ]
    }
  }
}

ruleset = Sashite::Ggn.parse(ggn_data)
board = { "e1" => "C:K", "e2" => nil }

transitions = ruleset.select("C:K").from("e1").to("e2").where(board)

Capture

# Pawn captures diagonally
ggn_data = {
  "C:P" => {
    "e4" => {
      "f5" => [
        {
          "must" => { "f5" => "enemy" },
          "deny" => {},
          "diff" => {
            "board" => { "e4" => nil, "f5" => "C:P" },
            "toggle" => true
          }
        }
      ]
    }
  }
}

ruleset = Sashite::Ggn.parse(ggn_data)
board = { "e4" => "C:P", "f5" => "c:p" }

transitions = ruleset.select("C:P").from("e4").to("f5").where(board)

Promotion Choices

# Pawn promotion with multiple outcomes
ggn_data = {
  "C:P" => {
    "e7" => {
      "e8" => [
        {
          "must" => { "e8" => "empty" },
          "deny" => {},
          "diff" => { "board" => { "e7" => nil, "e8" => "C:Q" }, "toggle" => true }
        },
        {
          "must" => { "e8" => "empty" },
          "deny" => {},
          "diff" => { "board" => { "e7" => nil, "e8" => "C:N" }, "toggle" => true }
        }
      ]
    }
  }
}

ruleset = Sashite::Ggn.parse(ggn_data)
board = { "e7" => "C:P", "e8" => nil }

transitions = ruleset.select("C:P").from("e7").to("e8").where(board)

transitions.each_with_index do |t, i|
  promoted_piece = t.board_changes["e8"]
  puts "Choice #{i + 1}: promotes to #{promoted_piece}"
end
# => Choice 1: promotes to C:Q
# => Choice 2: promotes to C:N

Complex Moves (Castling)

# King-side castling
ggn_data = {
  "C:+K" => {
    "e1" => {
      "g1" => [
        {
          "must" => {
            "f1" => "empty",
            "g1" => "empty",
            "h1" => "C:+R"
          },
          "deny" => {},
          "diff" => {
            "board" => {
              "e1" => nil,
              "g1" => "C:K",
              "h1" => nil,
              "f1" => "C:R"
            },
            "toggle" => true
          }
        }
      ]
    }
  }
}

ruleset = Sashite::Ggn.parse(ggn_data)
board = { "e1" => "C:+K", "f1" => nil, "g1" => nil, "h1" => "C:+R" }

transitions = ruleset.select("C:+K").from("e1").to("g1").where(board)

if transitions.any?
  puts "Castling is possible!"
  puts "Result: #{transitions.first.board_changes}"
  # => {"e1"=>nil, "g1"=>"C:K", "h1"=>nil, "f1"=>"C:R"}
end

En Passant

# En passant capture
ggn_data = {
  "C:P" => {
    "e5" => {
      "f6" => [
        {
          "must" => {
            "f6" => "empty",
            "f5" => "c:-p"  # Vulnerable pawn
          },
          "deny" => {},
          "diff" => {
            "board" => {
              "e5" => nil,
              "f6" => "C:P",
              "f5" => nil
            },
            "hands" => { "c:p" => 1 },
            "toggle" => true
          }
        }
      ]
    }
  }
}

ruleset = Sashite::Ggn.parse(ggn_data)
board = { "e5" => "C:P", "f5" => "c:-p", "f6" => nil }

transitions = ruleset.select("C:P").from("e5").to("f6").where(board)

Piece Drop (Shōgi-style)

# Drop pawn from hand with file restriction
ggn_data = {
  "S:P" => {
    "*" => {
      "e4" => [
        {
          "must" => { "e4" => "empty" },
          "deny" => {
            "e1" => "S:P", "e2" => "S:P", "e3" => "S:P",
            "e5" => "S:P", "e6" => "S:P", "e7" => "S:P",
            "e8" => "S:P", "e9" => "S:P"
          },
          "diff" => {
            "board" => { "e4" => "S:P" },
            "hands" => { "S:P" => -1 },
            "toggle" => true
          }
        }
      ]
    }
  }
}

ruleset = Sashite::Ggn.parse(ggn_data)
board = { "e4" => nil, "e5" => nil }  # No pawns on file

transitions = ruleset.select("S:P").from("*").to("e4").where(board)

Working with Board State

# Board state representation
board_state = {
  "e1" => "C:K",   # White king on e1
  "d1" => "C:Q",   # White queen on d1
  "e8" => "c:k",   # Black king on e8
  "e2" => nil,     # Empty square
  "f3" => nil      # Empty square
}

# Generate all moves for a position
ruleset = Sashite::Ggn.parse(ggn_data)
all_moves = ruleset.pseudo_legal_transitions(board_state)

all_moves.each do |piece, from, to, transitions|
  puts "#{piece}: #{from}#{to} (#{transitions.size} variants)"
end

# Check specific piece moves
king_moves = ruleset.select("C:K").from("e1")
queen_moves = ruleset.select("C:Q").from("d1")

Loading GGN Data

The gem focuses on processing GGN data structures. Loading from files is left to user preference:

# Using JSON (requires 'json' gem)
require 'json'
data = JSON.parse(File.read('chess_moves.json'))
ruleset = Sashite::Ggn.parse(data)

# Using YAML
require 'yaml'
data = YAML.load_file('chess_moves.yml')
ruleset = Sashite::Ggn.parse(data)

# Using MessagePack
require 'msgpack'
data = MessagePack.unpack(File.read('chess_moves.msgpack'))
ruleset = Sashite::Ggn.parse(data)

# Direct Ruby hash
data = {
  "C:K" => {
    "e1" => {
      "e2" => [
        {
          "must" => { "e2" => "empty" },
          "deny" => {},
          "diff" => { "board" => { "e1" => nil, "e2" => "C:K" }, "toggle" => true }
        }
      ]
    }
  }
}
ruleset = Sashite::Ggn.parse(data)

Advanced Usage

Move Generation and Filtering

# Generate all pseudo-legal moves
all_moves = ruleset.pseudo_legal_transitions(board_state)

# Filter for specific piece type
king_moves = all_moves.select { |piece, _, _, _| piece == "C:K" }

# Filter for captures
captures = all_moves.select do |_, _, _, transitions|
  transitions.any? { |t| t.board_changes.values.compact.size > 1 }
end

# Filter for promotion moves
promotions = all_moves.select do |piece, _, _, transitions|
  transitions.size > 1  # Multiple choices indicate promotion
end

Building Game Engines

class ChessEngine
  def initialize(ggn_data)
    @ruleset = Sashite::Ggn.parse(ggn_data)
  end

  def pseudo_legal_moves(board)
    @ruleset.pseudo_legal_transitions(board)
  end

  def valid_move?(piece, from, to, board)
    @ruleset.select(piece).from(from).to(to).where(board).any?
  rescue KeyError
    false
  end

  def make_move(piece, from, to, board, choice_index = 0)
    transitions = @ruleset.select(piece).from(from).to(to).where(board)
    return nil if transitions.empty?

    transition = transitions[choice_index]
    apply_transition(board, transition)
  end

  private

  def apply_transition(board, transition)
    new_board = board.dup
    transition.board_changes.each { |square, piece| new_board[square] = piece }
    new_board
  end
end

# Usage
engine = ChessEngine.new(chess_ggn_data)
board = { "e2" => "C:P", "e3" => nil, "e4" => nil }

if engine.valid_move?("C:P", "e2", "e4", board)
  new_board = engine.make_move("C:P", "e2", "e4", board)
end

Hybrid Games Support

# Mix pieces from different game systems
hybrid_ggn = {
  "C:K" => { /* chess king rules */ },
  "S:G" => { /* shogi gold general rules */ },
  "X:C" => { /* xiangqi cannon rules */ }
}

ruleset = Sashite::Ggn.parse(hybrid_ggn)

# Each piece follows its own movement rules
chess_king = ruleset.select("C:K").from("e1")
shogi_gold = ruleset.select("S:G").from("5e")
xiangqi_cannon = ruleset.select("X:C").from("e5")

Design Principles

  • Functional: Pure functions, no side effects
  • Immutable: All data structures are frozen
  • Minimal: No file I/O, no JSON parsing - pure data transformation
  • Composable: Method chaining for natural navigation
  • Spec-compliant: Strictly follows GGN v1.0.0 specification
  • Delegative: Leverages sashite-cell, sashite-qpi, sashite-stn for validation

Performance Characteristics

  • O(1) piece lookup
  • O(1) source-destination query
  • O(n) pre-condition evaluation (n = number of must/deny conditions)
  • Immutable data structures ensure thread safety
  • No parsing overhead - works with Ruby hashes directly

Error Handling

# Query for non-existent piece
begin
  source = ruleset.select("INVALID:X")
rescue KeyError => e
  puts "Unknown piece: #{e.message}"
end

# Query for invalid position
begin
  destinations = source.from("invalid_square")
rescue KeyError => e
  puts "Invalid source position: #{e.message}"
end

# Safe querying with defaults
def safe_move_check(ruleset, piece, from, to, board)
  ruleset.select(piece).from(from).to(to).where(board)
rescue KeyError
  []  # Return empty array if piece/location not found
end

# Validate data before parsing
if Sashite::Ggn.valid?(ggn_data)
  ruleset = Sashite::Ggn.parse(ggn_data)
else
  puts "Invalid GGN data structure"
end

Related Specifications

GGN is part of the Sashité ecosystem:

Development

# Clone the repository
git clone https://github.com/sashite/ggn.rb.git
cd ggn.rb

# Install dependencies
bundle install

# Run tests
bundle exec rake test

# 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
  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.