simulate.experiment.sh

Four games, one request.

Chess, Go, no-limit hold'em and Connect Four as HTTP environments for training agents. Read observations and legal actions, send moves, collect rewards, and step hundreds of matches in a single batch call.

Chess
Go 9×9
Hold'em
Connect Four
POST /v1/batch
Connecting…

Each step is one real request: four act operations with random legal moves.

simulate.experiment.sh provides game environments over HTTP for training and evaluating AI agents. It runs chess, Go, no-limit hold'em poker and Connect Four. You create a match, read its observation and legal actions, and send actions back. Rewards come back the moment a match (or a poker hand) finishes.

The batch endpoint is the main way to use it. One request can create matches, play moves in hundreds of them, roll them out to the end and fork them for search. That lets a training loop step a whole population of games in one round trip.

To measure an agent, run an evaluation session. A session schedules matches against other agents or built-in bots, rotates seats, replays the same cards or openings from both sides, and reports bb/100 or Elo with 95% confidence intervals.

Quick start

Create a chess match:

curl -s https://simulate.experiment.sh/v1/matches \
  -H 'content-type: application/json' \
  -d '{"game": "chess"}'
{
  "id": "m_Q2b7sV1kz0xYp9aE",
  "game": "chess",
  "status": "active",
  "ply": 0,
  "current_player": 0,
  "num_players": 2,
  "result": null,
  "returns": [0.0, 0.0],
  "observation": {
    "fen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
    "board": ["rnbqkbnr", "pppppppp", "........", "........",
              "........", "........", "PPPPPPPP", "RNBQKBNR"],
    "turn": "white",
    "in_check": false,
    "last_move": null,
    "fullmove_number": 1,
    "halfmove_clock": 0
  },
  "legal_actions": ["a2a3", "a2a4", "b1a3", "b1c3", "..."],
  "config": {},
  "seed": null,
  "parent": null,
  "created_at": "2026-10-20T16:02:11.402Z",
  "updated_at": "2026-10-20T16:02:11.402Z"
}

Play a move:

curl -s https://simulate.experiment.sh/v1/matches/m_Q2b7sV1kz0xYp9aE/actions \
  -H 'content-type: application/json' \
  -d '{"action": "e2e4"}'

The response holds rewards, the reward each player earned from this request, and applied, the number of actions applied. The match's new state is in state.

Do the same for four games in one request:

curl -s https://simulate.experiment.sh/v1/batch \
  -H 'content-type: application/json' \
  -d '{
    "operations": [
      {"op": "create", "game": "chess",    "label": "c"},
      {"op": "create", "game": "go",       "label": "g", "config": {"size": 9}},
      {"op": "create", "game": "poker",    "label": "p", "seed": 7},
      {"op": "create", "game": "connect4", "label": "f"},
      {"op": "act", "id": "@c", "actions": ["e2e4", "e7e5", "Nf3"]},
      {"op": "act", "id": "@g", "actions": ["E5", "C3"]},
      {"op": "act", "id": "@p", "action": "call"},
      {"op": "rollout", "id": "@f", "seed": 1}
    ]
  }'

Concepts

Match. One game in progress, stored on the server under an id such as m_Q2b7sV1kz0xYp9aE. A poker match can span several hands. Matches untouched for 7 days are deleted.

Players. Seats are numbered from 0. Chess: 0 is white. Go: 0 is black. Connect Four: 0 is X. Poker: seats 0 to players - 1. current_player is the seat to act, or null once the match is over.

Agents. A match can name an agent for each seat with agents, for example ["me", "bot:greedy"]. Seats named bot:<name> are played by the server the moment it is their turn, so you only ever act for your own seats. Without agents your client plays every seat. See Built-in bots.

Ply. The number of actions applied so far. Pass expect_ply with an action to make the server reject the action if another client has moved in the meantime.

Observation. A game-specific JSON description of the position (see Games). In poker each seat sees only its own hole cards; see Viewing poker.

Legal actions. legal_actions lists every action the current player may take as strings. Poker raises take an amount; their bounds are in observation.raise_range.

Rewards and returns. Chess, Go and Connect Four pay +1 to the winner and -1 to the loser when the match ends, and 0 to both for a draw. Poker pays each seat its chip gain or loss when each hand ends. rewards in an act or rollout response is what this request earned. returns in the state is the running total for the whole match.

Result. null while status is active. Once finished, it is an object with winner (a seat, or null for a draw or tie) and reason, plus game-specific fields such as Go's score.

Batch API

POST /v1/batch runs a list of operations in order and returns one result per operation. Operations in a batch can touch any number of matches and any mix of games, up to 1,000 operations per request.

Request

{
  "operations": [ {"op": "...", "...": "..."} ],
  "atomic": false,
  "observation": true,
  "legal_actions": true
}
Field Default Meaning
operations required Up to 1,000 operations, run in order.
atomic false Stop at the first failure, mark the rest skipped, and save nothing.
observation true Include observation in returned states. Turn off to shrink responses.
legal_actions true Include legal_actions in returned states.

Operations

op Fields Returns
create game, config, seed, agents, label, view state of the new match, bot_actions
get id, view, history state (with history if asked)
act id, action or actions, player, agent, expect_ply, view state, rewards, applied, bot_actions
rollout id, steps, seed, view state, rewards, actions, truncated
fork id, label, reshuffle_seed, view state of the copy
delete id deleted: true
create_session game, agents, matches, config, duplicate, opening_plies, seed, label session
next session, count, view matches (states of the started matches), session
session session, report session, with report unless report is false
delete_session session deleted: true

Response

{
  "results": [
    {"index": 0, "op": "create", "label": "c", "ok": true, "id": "m_...", "state": {"...": "..."}},
    {"index": 1, "op": "act", "ok": true, "id": "m_...", "state": {"...": "..."},
     "rewards": [0.0, 0.0], "applied": 3},
    {"index": 2, "op": "act", "ok": false, "id": "m_...",
     "error": {"code": "illegal_action", "message": "'e2e5' is not a legal move in this position"}}
  ],
  "summary": {"operations": 3, "succeeded": 2, "failed": 1, "committed": true,
              "steps": 4, "elapsed_ms": 3.1}
}

The batch itself returns HTTP 200 whenever the body is well formed, even if some operations fail. Check each result's ok. A failed operation changes nothing. Later operations still run unless atomic is set. summary.committed is false only when an atomic batch failed and nothing was saved.

Each batch runs inside one database transaction. Two batches that touch the same match run one after the other, never interleaved.

Example: stepping many games at once

A vectorised training loop keeps a list of match ids and sends one batch per step:

import random
import requests

API = "https://simulate.experiment.sh"
N = 64

def batch(operations, **options):
    r = requests.post(f"{API}/v1/batch", json={"operations": operations, **options})
    r.raise_for_status()
    return r.json()["results"]

states = [r["state"] for r in batch([{"op": "create", "game": "connect4"}] * N)]

while any(s["status"] == "active" for s in states):
    active = [s for s in states if s["status"] == "active"]
    ops = [
        {"op": "act", "id": s["id"], "action": random.choice(s["legal_actions"]),  # your policy
         "expect_ply": s["ply"]}
        for s in active
    ]
    by_id = {r["id"]: r["state"] for r in batch(ops) if r["ok"]}
    states = [by_id.get(s["id"], s) for s in states]

print([s["result"]["winner"] for s in states])
batch([{"op": "delete", "id": s["id"]} for s in states])

Example: Monte Carlo evaluation with fork

To score each candidate move from a position, fork the match once per move, play the move, roll the copy out several times, then clean up:

{"operations": [
  {"op": "fork", "id": "m_root", "label": "a"},
  {"op": "act", "id": "@a", "action": "3"},
  {"op": "rollout", "id": "@a", "seed": 1},
  {"op": "delete", "id": "@a"},
  {"op": "fork", "id": "m_root", "label": "b"},
  {"op": "act", "id": "@b", "action": "4"},
  {"op": "rollout", "id": "@b", "seed": 1},
  {"op": "delete", "id": "@b"}
], "observation": false, "legal_actions": false}

Each rollout result's rewards is the outcome of that playout. A match created and deleted in the same batch is never written to storage.

Evaluation

Single matches say little about an agent. The first mover in chess and the button in poker have built-in edges, card luck swamps skill over hundreds of hands, and one bust in a long poker match ends the measurement. An evaluation session handles all of this. You list the agents and how many matches to play. The server schedules them, rotates seats, plays any built-in bots, and keeps a report with confidence intervals.

Creating a session

curl -s https://simulate.experiment.sh/v1/sessions \
  -H 'content-type: application/json' \
  -d '{"game": "poker", "agents": ["mine", "bot:tight"], "matches": 4000, "seed": 1}'
Field Default Meaning
game required Any game.
agents required One unique name per seat. bot:<name> entries are played by the server.
matches required Matches to schedule, up to 100,000. With duplicate, a multiple of the number of seats.
config {} Game options, as for a match. Poker sessions always play one hand per match.
duplicate true Replay each deal (poker) or opening (board games) once per seat rotation.
opening_plies 0 Board games: play this many random moves before the agents take over.
seed random Makes the schedule, the cards and the openings reproducible. Echoed only if you set it.

Two agents can be two versions of your own model (["v7", "v6"]), your model against a bot, or two bots for a baseline. Client agents are just names. Your code decides which policy answers when current_agent is one of them.

Running a session

Start matches with next, play them like any other match, and repeat until next returns no matches. Bots move on their own, so a match is always waiting for one of your agents or already finished. Each match carries agents, current_agent, session and session_index. Matches started together can be played in parallel through the batch endpoint.

import requests

API = "https://simulate.experiment.sh"

def policy(state):  # replace with your agent
    legal = state["legal_actions"]
    return "call" if "call" in legal else legal[0]

session = requests.post(f"{API}/v1/sessions", json={
    "game": "poker", "agents": ["mine", "bot:tight"], "matches": 2000, "seed": 1,
}).json()

while True:
    started = requests.post(f"{API}/v1/sessions/{session['id']}/next",
                            json={"count": 200}).json()["matches"]
    if not started:
        break
    active = [s for s in started if s["status"] == "active"]
    while active:
        ops = [{"op": "act", "id": s["id"], "agent": "mine", "action": policy(s)}
               for s in active]
        results = requests.post(f"{API}/v1/batch", json={"operations": ops}).json()["results"]
        active = [r["state"] for r in results if r["ok"] and r["state"]["status"] == "active"]

report = requests.get(f"{API}/v1/sessions/{session['id']}").json()["report"]
for agent in report["agents"]:
    print(agent["agent"], agent["bb_per_100"], agent["bb_per_100_ci95"])

If your client stops part-way, GET /v1/sessions/{id} lists the unfinished match ids in active. Resume them, then carry on with next.

How matches are scheduled

With n seats, match i uses seat rotation i mod n. Agent k sits in seat (k − rotation) mod n, so every agent plays every seat equally often: white and black in chess, the button and the blinds in poker.

With duplicate on (the default), the n consecutive matches of each rotation cycle share one deal:

Built-in bots draw their randomness from the deal and the position, so the same seed replays the same session exactly.

Reading the report

GET /v1/sessions/{id} returns progress (matches, started, finished, status) and a report with one entry per agent:

{
  "scored_matches": 4000, "finished_matches": 4000, "groups": 2000, "group_size": 2,
  "agents": [
    {"agent": "bot:tight", "builtin": true, "illegal_actions": 0, "hands": 4000,
     "net_chips": 2265.0, "net_big_blinds": 1132.5,
     "bb_per_100": 28.31, "bb_per_100_ci95": [19.98, 36.65], "mbb_per_hand": 283.1,
     "by_position": {"button": {"hands": 2000, "bb_per_100": -23.62},
                     "big_blind": {"hands": 2000, "bb_per_100": 80.25}}},
    {"agent": "bot:calling_station", "...": "..."}
  ]
}

That is bot:tight against bot:calling_station over 4,000 hands with "seed": 1.

Poker results are in big blinds, so they compare across blind sizes:

Board games are scored 1 for a win, ½ for a draw and 0 for a loss:

Every agent also gets illegal_actions, the number of actions it submitted that were rejected as illegal. This is often the first thing to fix in a language-model agent. Rejected actions change nothing, and the agent tries again.

Method. Statistics are computed over groups. With duplicate, a group is one deal or opening played in every rotation, and its value is the agent's average over those matches. Without it, each match is a group. Only complete groups are scored, so a running session's report is unbiased, and scored_matches can trail finished_matches by a few. Intervals are normal approximations: mean ± 1.96 × the standard error of the group values. GET /v1/sessions/{id}/results returns every match's raw outcome (deal, rotation, agents, returns, winner, reason, plies) if you want to compute your own statistics.

How many matches

The interval shrinks with the square root of the number of groups: four times the matches halves its width. Some reference points:

Game Matches Typical 95% interval
Heads-up poker 4,000 hands about ±8 bb/100 (tight bot against a calling station)
Heads-up poker 40,000 hands about ±2.5 bb/100
Board game, evenly matched 100 games about ±0.1 score, ±70 Elo
Board game, evenly matched 400 games about ±0.05 score, ±35 Elo

Duplicate scheduling removes card luck that both agents would play the same way. It narrows intervals most when agents play similar strategies, and only a little when they play very differently. Decide the number of matches before you start. Stopping the first time the interval excludes zero makes false positives much more likely.

Fair play

So that a report measures the agent and nothing else, session matches have these restrictions while they are running:

There is no authentication, so these rules keep honest clients honest rather than stop a determined cheat. Finished matches are unrestricted.

Built-in bots

GET /v1/bots lists these. Use them as fixed reference opponents. They are simple on purpose, so a learning agent should beat all of them.

Agent Games Plays
bot:random all A uniformly random legal action. In Go it never fills its own eye.
bot:greedy chess Mate in one, otherwise the best one-move material trade.
bot:greedy Go The biggest capture, avoiding self-atari.
bot:greedy Connect Four Wins, blocks, avoids handing over a win, prefers the centre.
bot:calling_station poker Never folds or raises.
bot:maniac poker Raises the pot whenever it can.
bot:tight poker Raises strong starting hands, calls medium ones, folds the rest. After the flop it bets two pair or better and calls with a pair.

Bots work in ordinary matches too: {"op": "create", "game": "chess", "agents": ["me", "bot:greedy"]} gives a training opponent that replies as soon as you move.

Endpoints

The single-match endpoints run one batch operation and return its result directly. Errors use the HTTP statuses listed in Errors.

Method and path Operation
GET /v1/games List games with their rules, action formats and config options.
GET /v1/games/{game} Describe one game.
POST /v1/matches create. Body: game, config, seed, view. Returns 201 and the state.
GET /v1/matches/{id}?view=&history= get.
POST /v1/matches/{id}/actions act. Body: action or actions, player, expect_ply, view.
POST /v1/matches/{id}/rollout rollout. Body (optional): steps, seed, view.
POST /v1/matches/{id}/fork fork. Body (optional): reshuffle_seed, view. Returns 201.
DELETE /v1/matches/{id} delete.
POST /v1/sessions create_session. Returns 201 and the session.
GET /v1/sessions/{id}?report= session: progress and the report.
POST /v1/sessions/{id}/next next. Body (optional): count, view.
GET /v1/sessions/{id}/results?offset=&limit= Per-match results in schedule order (up to 10,000 per page).
DELETE /v1/sessions/{id} delete_session: the session, its results and its unfinished matches.
GET /v1/bots Built-in bots for each game.
POST /v1/batch Any mix of operations.
GET /health Readiness check.

Games

GET /v1/games returns the same information in machine-readable form.

Chess

"game": "chess". Standard rules, including castling, en passant, promotion and under-promotion.

Config Default Meaning
fen start position Start from any legal position.
max_plies 500 Declare a draw after this many plies (1-2000).

Go

"game": "go". Area scoring with positional superko. Suicide is illegal.

Config Default Meaning
size 9 Board size, 5-19.
komi 7.5 Points added to white's score.
max_moves 3 * size * size (capped at 2000) Score the board after this many moves.

Poker

"game": "poker". No-limit Texas hold'em for 2 to 9 players.

Config Default Meaning
players 2 Seats, 2-9.
stack 200 Starting chips per seat.
small_blind 1 Small blind.
big_blind 2 * small_blind Big blind.
hands 1 Hands to play. The match ends early when one seat holds every chip.
reset_stacks false Refill every seat to stack before each hand. Nobody busts, so all hands are played.

Viewing poker

view decides whose hole cards appear in observation.players[*].cards:

Anyone with the match id can ask for any view. The server does not hide information between clients, so keep views honest in your own training code.

For search over hidden information, fork with reshuffle_seed reshuffles the undealt deck in the copy, so rollouts from the fork see different runouts. Opponents' hole cards are not resampled.

Connect Four

"game": "connect4". Seven columns, six rows. Four in a row horizontally, vertically or diagonally wins. A full board is a draw.

Errors

Single-match endpoints return an HTTP error status. Batch results carry the same error object inside the failing result:

{"error": {"code": "illegal_action", "message": "column 3 is full"}}
Code HTTP Meaning
invalid_request 400 The body or a field is malformed.
invalid_config 400 A game option is out of range.
unknown_game 400 game is not one of the available games.
not_found 404 No match with that id (deleted, or expired after 7 days idle).
unknown_label 404 @name was not created earlier in the batch.
not_your_turn 409 player did not match the seat to act.
ply_mismatch 409 expect_ply did not match the match's ply.
game_over 409 The match has already finished.
session_match 409 Not allowed on a scored session match (rollout, or deleting it unfinished).
hidden_information 403 A running poker session match only shows the acting seat's view, and cannot be forked.
illegal_action 422 The action is not legal in this position. The message says why.
batch_too_large 413 More than 1,000 operations.
step_budget_exceeded 413 More than 20,000 actions and rollout moves in one request.
skipped Atomic batch only: not run because an earlier operation failed.
capacity 503 The server is holding its maximum number of matches.

Limits