New games, structured types, crossword puzzles, language model integration, and more!
We're delighted to announce the release of OpenSpiel 2.0! This release includes:
- New core API features: state, action, and observation structs and standard trajectories.
- 19 new games: Ant Foraging (python), Antichess, Banqi (Chinese Dark Chess), Capture the Flag, Crazyhouse, Garnet (mean-field game), Gomoku, Go Fish, Hangman (python), Latent Tic-Tac-Toe, Lines of Action, Poker game via pokerkit wrapper (python), Repeated Leduc Poker, Repeated Poker, Shogi, Snake, Social deduction, Xiangqi (Chinese Chess), and Yacht.
- AlphaZero ported to JAX.
- Windows PyPI support: Install OpenSpiel directly using pip install on Windows.
- Language model training examples: Fine-tune Gemma and other models to play OpenSpiel games.
- MCP server example: Use a language model interface to play OpenSpiel games. Demo below!
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:
- Send game states over the wire as JSON to a frontend, an LLM, or a database, without ad-hoc parsing.
- Construct game states programmatically from JSON, enabling test fixtures, scenario replay, and state injection without replaying action histories.
- Work with rich, multi-field actions (like placing a word at a position in Crossword) instead of mapping everything to a single integer.
- Use the same representation in C++ and Python — Python gets native dicts via state.to_dict().
There are four kinds of structs:
- State structs represent the full ground state.
- Observation structs represent the observation to a particular player.
- Action structs allow for more complex actions than flat integers.
- Game parameter structs provide type-safe game configuration.
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:
- Sacha Chernyavskiy for the AlphaZero JAX port as well as many fixes, refactors, and general support.
- Vishesh Rawal for Windows pip/PyPI support.
- George Weinberg for many excellent game contributions.
- Clayton Drazner for many poker fixes, new poker variants, and help supporting the game arena.
- @fuyuan-li and Brian Xiao for extending the fine-tuning examples to Hugging Face and Gemma 4.
- Parv Maheshwari and Harman Agrawal for their initial implementation of the JAX port of AlphaZero.