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.
- Base URL:
https://simulate.experiment.sh - Format: JSON in, JSON out. Send
Content-Type: application/json. - Authentication: none. A match id is the only key to a match, so treat ids as secrets if that matters to you.
- Interactive reference: /reference (OpenAPI at /openapi.json). This guide as Markdown: /llms.txt.
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 |
idis a match id, or@nameto refer to a match that an earliercreateorforkin the same batch gave"label": "name".actionsapplies several actions in order, alternating players as the game dictates. It is all or nothing: if any action is illegal the match is left as it was.playermakesactfail withnot_your_turnunless every action in the operation is taken by that seat.agentdoes the same by agent name, which is easier in a session where seats rotate. Use one of them when an agent submits only its own moves.bot_actionslists the moves built-in bots made after your action, as{"player", "agent", "action"}.rewardsincludes what those moves earned.rolloutplays uniformly random legal moves until the match ends orstepsmoves have been played. Go rollouts never fill the mover's own single-point eyes, and pass only when no other move is left. Aseedmakes the moves reproducible.forkcopies a match, including its history and returns, into a new match with its own id (parentpoints at the source). Use it for tree search: fork, try a line, read the outcome, delete.
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:
- Poker: every rotation gets the same cards, so when one agent is dealt aces, the other gets the same aces in the mirrored match. Each match is one hand with full stacks (the cash-game format used in poker AI research), so a big loss cannot end the evaluation early.
- Board games: every rotation starts from the same position after
opening_pliesrandom moves, played once with each colour. Use a few opening plies when an agent is deterministic. Otherwise every game between two fixed agents is the same game.
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:
bb_per_100: big blinds won per 100 hands, the standard win rate.mbb_per_handis the same number in milli-big-blinds per hand (1 bb/100 = 10 mbb/hand), the unit used in poker AI papers.bb_per_100_ci95: the 95% confidence interval. If it excludes 0, the result is unlikely to be luck.by_position: win rate from each seat (button,small_blind,big_blind, thenseat_3and up).
Board games are scored 1 for a win, ½ for a draw and 0 for a loss:
wins,draws,losses, andscore, the average.score_ci95: the 95% confidence interval forscore.eloandelo_ci95: the rating difference against the other agent implied byscore. Elo isnullat a score of exactly 0 or 1, where it is unbounded.los: likelihood of superiority, the probability that this agent is genuinely stronger, from wins and losses.by_seat: results as white/black (chess), black/white (Go) or first/second (Connect Four).average_margin(Go): average points won by, negative when behind.
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:
- An agent cannot act for a seat played by a built-in bot.
- Poker matches only show the acting seat's view (
viewmust becurrent), and cannot be forked, since a copy would reveal the deck and the other hands. - Session matches cannot be rolled out, and cannot be deleted until they finish. Fork a chess, Go or Connect Four match to search from a copy.
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). |
- Actions: UCI (
e2e4,e7e8q) or SAN (Nf3,O-O,exd8=Q+).legal_actionsis sorted UCI.resignis accepted from the player to move. - Ends on: checkmate, stalemate, insufficient material, threefold repetition, the
fifty-move rule, resignation, or
max_plies.result.reasonis one ofcheckmate,stalemate,insufficient_material,threefold_repetition,fifty_moves,fivefold_repetition,seventyfive_moves,resignation,max_plies. - Observation:
fen,board(8 strings from rank 8 to rank 1, uppercase white,.empty),turn,in_check,last_move,fullmove_number,halfmove_clock.
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. |
- Actions: a point in GTP notation (
D4,Q16). Columns runAtoTwithoutI, and row 1 is the bottom. Alsopassandresign. Case does not matter. - Ends on: two consecutive passes, resignation, or
max_moves. The board is then scored by area: stones plus empty regions that touch only one colour, with komi added for white. Every stone on the board counts as alive, so capture dead stones before passing.result.scoreholdsblack,whiteandmargin. - Observation:
size,board(rows from the top,Xblack,Owhite,.empty),to_play,komi,captures,consecutive_passes,last_move,move_number, andarea_score, the score if the game ended now. - Action indices: if your network wants integers, point
(row, col)with row 0 at the top maps tocol_letter + str(size - row).
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. |
- Actions:
fold(only when facing a bet),check,call.raise:<total>, where<total>is your whole bet for this street, not the amount added. Bounds are inobservation.raise_range.raise:min,raise:half_pot,raise:potandraise:max. Bareraisemeansraise:min.bet:<total>is the same asraise:<total>, andallinis a shortcut for the biggest legal raise or call.legal_actionsnames the action kinds (fold,call,raiseorcheck,raise).- Rules:
- Heads-up, the button posts the small blind and acts first before the flop.
- An all-in raise smaller than a full raise does not reopen raising for players who have already acted.
- Side pots are split among the best eligible hands. Odd chips go to the first winner left of the button.
- When no more betting is possible, the remaining board is dealt out automatically.
- The button moves to the next seat with chips after each hand.
- Rewards: each seat's chip gain or loss, paid when a hand ends.
returnsis the match total. - Result:
winneris the seat with the most chips (nullon a tie), plusstacks,hands_played, and each seat'snet_chips,net_big_blindsandbb_per_100. Withreset_stacksthe winner is the seat that won the most chips. - Observation:
- Hand state:
hand_number,street,board,button,blinds,pot,current_bet,to_call,raise_range, andactions(this hand so far). players: each seat'sstack,bet(this street),committed(this hand),status(active,folded,allinorout), andcards.last_hand: the previous hand's board, actions, pots, payouts and showdown cards.- Seeds: the deck for hand k is shuffled from
seedand k, so the same seed and the same actions replay the same match.seedis echoed in the state only when you supplied it. A server-chosen seed stays private so agents cannot predict the cards. - History: the state keeps the last 100 finished hands.
Viewing poker
view decides whose hole cards appear in observation.players[*].cards:
"current"(default): the seat to act, which is what that agent should see. Once the match is over, every hand is shown.- A seat number: that seat's view, whoever is to act.
"all": every hand, for logging or a referee.
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.
- Actions: column
0-6, as a string or an integer. - Observation:
board(6 strings from the top row down,X,O,.),to_play,last_move.
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
- 1,000 operations and 20,000 actions plus rollout moves per request. Bot moves are not limited, since they always end with the game.
- Sessions schedule up to 100,000 matches, and
nextstarts up to 1,000 at a time. - About 25 seconds of work per request. A rollout still running at that point stops
early with
truncated: true; call it again to continue. - Matches expire after 7 days without activity. Delete them when you are done.
- One host serves every client. For large jobs, prefer fewer, larger batches over many small concurrent ones.