New games, structured types, crossword puzzles, language model integration, and more!

OpenSpiel 2.0 image
Choose from over 120 games with OpenSpiel

We're delighted to announce the release of OpenSpiel 2.0! This release includes:

In this post, we will highlight a few new key features: trajectories, state/observation/action structs, a game that uses action structs only (Crossword), and the MCP server example that allows you to hook up OpenSpiel games to a language model.

Much of what is new in OpenSpiel has been designed for it to be easier to use with language models in particular. Specifically, OpenSpiel feature design has benefited from their use as game environments in Kaggle Game Arena: a dynamic benchmark for evaluating game-playing performance of large language models (LLMs) across many games and game types.

Standard Trajectories

A trajectory is a simple container representing a sequence of states and actions and associated with some meta data, used to record games. It can be instantiatied to be initially empty, or from a string (JSON-formatted), JSON object, or a state object (which contains the history):
class Trajectory {
 public:
  Trajectory() = default;
  Trajectory(const std::string& json_str);
  Trajectory(const nlohmann::json& json);

  // Uses the history of the final state to construct the trajectory.
  // Only adds the required fields to the trajectory.
  Trajectory(const State* final_state);

  // Returns a string representation of the trajectory (json-formatted).
  std::string ToString() const;

  std::unique_ptr<State> ReconstructFinalState() const;
  std::vector<std::unique_ptr<State>> ReconstructAllStates() const;

  const Header& header() const;
  const std::vector<Transition>& transitions() const;
}; 
The string representation of the trajectory is the sequence of states and actions expressed in a readable JSON format. Here is a minimal example:
{
  "header": {
    "game_string": "tic_tac_toe",
    "terminal": true,
    "returns": [1, -1],
    "meta_data": "some_extra_info"
  },
  "transitions": [
    { "player": 0, "action": 4 },
    { "player": 1, "action": 3 },
    { "player": 0, "action": 6 },
    { "player": 1, "action": 2 },
    { "player": 0, "action": 8 },
    { "player": 1, "action": 0 },
    { "player": 0, "action": 7 }
  ]
}
As always with core functionality, trajectories are fully exposed to Python; see utils_trajectories_test.py for usage examples.

The aim of the trajectories helper utility is to provide a unified game-agnostic way to describe, store, and exchange OpenSpiel game-play logs, instead of having different standards for each game (PGN for chess, SGF for go, etc.). While sequence of actions integers (i.e. a history) can be used in most games to uniquely identify a state, trajectory objects are a richer representation allowing the storing of extra (structured) context often useful to users.

State, Observation, and Action Structs

Historically, OpenSpiel's primary representations for states, observations, and actions have been either ASCII-like strings or tensors. These work well for classical algorithms like CFR or AlphaZero, but they make it hard to build systems that need to ingest and manipulate a game's state — things like LLM-based agents, web UIs, game analysis tools, etc.

Structs solve this by providing a typed, JSON-interchangeable representation for every game. Each struct is a plain C++ object whose fields directly describe the game state in natural terms (boards, hands, cards, players), and every struct can be serialized to JSON and deserialized back losslessly. This means you can:

There are four kinds of structs:

All structs inherit from a single base class, which is a JSON object based on the flexible JSON C++ library by Niels Lohmann:
struct SpielStruct {
  virtual ~SpielStruct() = default;
  SpielStruct() = default;

  std::string ToJson() const { return to_json_base().dump(); }
  virtual nlohmann::json to_json_base() const = 0;
};

struct StateStruct : public SpielStruct {};
struct ObservationStruct : public SpielStruct {};
struct ActionStruct : public SpielStruct {};

struct GameParametersStruct : public SpielStruct {
  std::string game_name;  // Required: identifies which game this configures
  nlohmann::json to_json_base() const override {
    return nlohmann::json{{"game_name", game_name}};
  }
};

Defining Structs

Let's see what these structs look like for Connect Four. Since it's a perfect information game, the states and observations coincide. There are helpful macros to avoid excessive boilerplate code. For example, for Connect Four we can define the classes using a simple contents class and two macros:
struct ConnectFourStructContents {
  std::vector<std::vector<std::string>> board;
  std::string current_player;
  bool is_terminal;
  std::string winner;
  NLOHMANN_DEFINE_TYPE_INTRUSIVE(ConnectFourStructContents, board,
                                 current_player, is_terminal, winner);
};

SPIEL_DEFINE_STRUCT(ConnectFourStateStruct, StateStruct,
                    ConnectFourStructContents);
SPIEL_DEFINE_STRUCT(ConnectFourObservationStruct, ObservationStruct,
                    ConnectFourStructContents);
Action structs have similar helpful macros, and in Connect Four the action struct is simple:
struct ConnectFourActionStruct : public ActionStruct {
  int column;
  SPIEL_STRUCT_BOILERPLATE(ConnectFourActionStruct, column);
};
For imperfect-information games, the observation struct will typically differ from the state struct-- adding fields like observing_player and hiding private information. The SPIEL_DEFINE_STRUCT_WITH_EXTRAS macro supports this pattern by merging a shared contents class with observation-specific fields.

Game Parameter Structs

Games can also define a typed parameter struct as an alternative to the string-based LoadGame("connect_four(rows=8)") API:
struct ConnectFourGameParams : public GameParametersStruct {
  int rows = kDefaultNumRows;
  int columns = kDefaultNumCols;
  int x_in_row = kDefaultXInRow;
  bool egocentric_obs_tensor = kDefaultEgocentricObsTensor;

  ConnectFourGameParams() { game_name = "connect_four"; }

  NLOHMANN_DEFINE_TYPE_INTRUSIVE(ConnectFourGameParams, game_name, rows,
                                 columns, x_in_row, egocentric_obs_tensor)
  nlohmann::json to_json_base() const override { return *this; }
};
This lets you configure games with compile-time checked fields:
ConnectFourGameParams params;
params.rows = 8;
params.columns = 8;
auto game = LoadGame(params);
You can also load a game directly from a JSON string — no struct definition needed on the caller side:
auto game = LoadGameFromJson(
    R"({"game_name": "connect_four", "rows": 4, "columns": 5, "x_in_row": 3})");
game = pyspiel.load_game_from_json(
    '{"game_name": "connect_four", "rows": 4, "columns": 5, "x_in_row": 3}')

Python Example

All struct methods are available in Python. The to_dict() method returns a native Python dictionary, making it easy to inspect states, pass them to LLMs, or serialize them however you like:
import pyspiel
import json

game = pyspiel.load_game("connect_four")
state = game.new_initial_state()
state.apply_action(3)  # Drop piece in column 3 (middle column, numbered 0 .. 6)

# Get the state as a Python dict
d = state.to_dict()
print(json.dumps(d, indent=2))
# {
#   "board": [[".", ".", ".", "x", ".", ".", "."], ...],
#   "current_player": "o",
#   "is_terminal": false,
#   "winner": "none"
# }

# Get as a JSON string directly
json_str = state.to_json()

# Reconstruct a state from JSON
state2 = game.new_initial_state(json_str)

# Convert between actions and structs
action_struct = state.action_to_struct(3)
action_struct.to_json()  # '{"column": 3}'
actions = state.struct_to_actions(action_struct)  # [3]

# Get observation for a specific player
obs = state.to_observation_struct(0)
obs.to_json()

ActionStruct-only Games: Crossword

There are some games for which representing actions as flat integers can be difficult. For example, imagine a crossword game with 50 clues, both across and down, with a valid word list containing 80,000 words. Using flat integers would lead to 8 million distinct actions! Constructing a set of legal actions would be laborious and could lead to unnecessary overhead.

For these use cases, we are expanding the API to allow "action struct only" games. To do this, we have added a new field to the GameType struct in spiel.h which indicates whether the game is of this type (defaults to false).

Crossword is a new OpenSpiel game that uses action structs only. It is a classic single-player word game with clues where players try to solve the clues using words that match letters in a grid/board. Puzzles are represented using the xd format and there are two modes: a random puzzle can be sampled from a directory containing a collection of puzzles, or a default puzzle is used if such a directory is not specified.

The initial state for the default puzzle is shown below. The clue for 1 Across (clue id "A1") and 1 Down (clue id "D1") both start at the top-left cell. The answer for A1 has two letters because the third column in the top row is blocked:
rows: 4, cols: 4
num_actions: 0
return: 0

Board:

1  2  ## 3
 .  . ##  .
4     6    
 .  .  .  .
   ## 7  
 . ##  .  .
8        
 .  .  .  .

Clues:

A1. Ancient board game with black and white stones
A4. Dry, like a desert
A7. Teaching assistant
A8. Dutch cheese
D1. Chess is an example of a board ____
D2. Disjunction (logical operator)
D3. Popular optimizer
D6. Invitation to Apply, abbreviated

Sampling and Applying ActionStructs in Crossword

Games in this category do not support some core API functions, such as LegalActions. Instead, legal actions can be sampled using an action struct sampler:

class ActionStructSampler {
 public:
  ActionStructSampler(const State* state, int rng_seed)
      : state_(state), rng_(rng_seed) {}
  virtual ~ActionStructSampler() = default;

  // Must be overridden by subclass.
  virtual std::unique_ptr<ActionStruct> SampleActionStruct();
};
Each action-struct-only game must provide a subclass that overrides the SampleActionStruct function. For example, see CrosswordActionStructSampler. Also, the game's state class must also override State::GetActionStructSampler. There are no restrictions placed on the sampling and each game is free to sample actions in a game-specific manner.

The ActionStructSpec function specifies the JSON fields and their types, and gives an example of a formatted action. Here is an example, in Python, of how to take an action by constructing one manually, and sampling a legal action:

game = pyspiel.load_game("crossword")

# Print the action struct spec, which specifies the fields and types along with an example.
print(game.action_struct_spec())
# {"clue_id": string, "word": string}, {"clue_id": "A1", "word": "GO"}"

state = game.new_initial_state()

# Constructing a specific action struct
action_struct = pyspiel.crossword.ActionStruct('{"clue_id": "A1", "word": "go"}')
status = state.validate_action_struct(action_struct)
assert status.ok()
state.apply_action_struct(action_struct)

# Sampling from an action struct sampler
as_sampler = state.get_action_struct_sampler(seed=42)
action_struct = as_sampler.sample_action_struct()
print(f"Sampled action: {action_struct.to_json()}")
status = state.validate_action_struct(action_struct)
assert status.ok()
state.apply_action_struct(action_struct)

Playing Games with Antigravity-CLI

Game on!   :)

PR #1558 adds a simple MCP tool server based on FastMCP. The MCP server allows users to interact with OpenSpiel games directly in text via a language model interface. See the video below for an overview of the code and an example of playing games using Gemini via Antigravity CLI.

Acknowledgments

We'd like to thank Google DeepMind for their continued support of OpenSpiel.

We'd like to extend a special thanks to the Kaggle Game Arena team for using OpenSpiel in several of their board game environments. Much of the design of OpenSpiel 2.0 was inspired by Kaggle Game Arena use cases, such as language models interacting with game environments.

We'd also like to offer special thanks to a few key contributors:

For a full list of contributors, see here.