Control Protocol
Developer overview · Event protocol
External services submit simulations through a ZeroMQ REQ socket connected to
tcp://wintermute:41970. Messages are JSON objects using protocol version 1.
Install the Python client dependency:
pip install pyzmq
Submit a Configuration
The submission payload contains the configuration object itself, not a filename:
{
"protocol_version": 1,
"request_id": "4d76309f-7734-49cc-a1b2-ddecd9fc9664",
"method": "simulation.submit",
"payload": {
"config": {
"epochs": 1500,
"seed": 1970
}
}
}
Partial configurations are accepted. Snake Lab applies the defaults and validates the result against the JSON Schema.
Python Client Stub
import asyncio
import json
import sys
import uuid
from pathlib import Path
import zmq
import zmq.asyncio
PROTOCOL_VERSION = 1
ENDPOINT = "tcp://wintermute:41970"
TIMEOUT_SECONDS = 3
class SnakeLabClient:
def __init__(self, endpoint: str = ENDPOINT) -> None:
self.endpoint = endpoint
self.context = zmq.asyncio.Context()
async def request(self, method: str, payload: dict) -> dict:
socket = self.context.socket(zmq.REQ)
socket.setsockopt(zmq.LINGER, 0)
socket.connect(self.endpoint)
request_id = str(uuid.uuid4())
message = {
"protocol_version": PROTOCOL_VERSION,
"request_id": request_id,
"method": method,
"payload": payload,
}
try:
await asyncio.wait_for(
socket.send_json(message), TIMEOUT_SECONDS
)
response = await asyncio.wait_for(
socket.recv_json(), TIMEOUT_SECONDS
)
finally:
socket.close()
if not isinstance(response, dict):
raise RuntimeError("invalid Snake Lab response")
if response.get("protocol_version") != PROTOCOL_VERSION:
raise RuntimeError("unsupported Snake Lab protocol")
if response.get("request_id") != request_id:
raise RuntimeError("Snake Lab request_id mismatch")
if response.get("status") != "ok":
error = response.get("error", {})
code = error.get("code", "request_failed")
message = error.get("message", "Snake Lab request failed")
raise RuntimeError(f"{code}: {message}")
return response["payload"]
async def submit(self, config: dict) -> dict:
return await self.request(
"simulation.submit", {"config": config}
)
def close(self) -> None:
self.context.term()
async def main(config_path: str) -> None:
config = json.loads(Path(config_path).read_text(encoding="utf-8"))
if not isinstance(config, dict):
raise TypeError("simulation config must be a JSON object")
client = SnakeLabClient()
try:
result = await client.submit(config)
print(json.dumps(result, indent=2))
finally:
client.close()
asyncio.run(main(sys.argv[1]))
Run it with:
python submit_simulation.py config.json
Responses
Every response echoes protocol_version and request_id. The example client
validates that envelope and returns only payload.
A new submission payload contains its run identifier and queue position:
{
"run_id": "f6e72cb3-9bcf-4669-b368-a17c656bad79",
"state": "queued",
"queue_position": 1
}
Every valid submission returns state: "queued" and a new run_id, even when
the same resolved configuration has already been submitted on this project
version. Earlier runs and their results are preserved. Callers are responsible
for duplicate detection or result reuse if needed. Repeating a request,
including retrying after a timeout, submits another run.
Protocol and configuration failures use this raw response shape:
{
"protocol_version": 1,
"request_id": "4d76309f-7734-49cc-a1b2-ddecd9fc9664",
"status": "error",
"error": {
"code": "invalid_config",
"message": "$.seed: -1 is less than the minimum of 0"
}
}
The example client converts this into a RuntimeError.
Supported Methods
The server binds a REP socket on port 41970 by default. --port changes
this port; --address selects the bind address for all three interfaces.
Requests contain exactly protocol_version (1), a non-empty string
request_id, a non-empty string method, and an object payload.
Payload fields must match the selected method exactly.
| Method | Payload | Successful response payload |
|---|---|---|
health |
{} |
{"service": "snake-lab", "project_version": "1.6.8"} |
simulation.submit |
{"config": {...}} |
run_id, state: "queued", queue_position |
simulation.active |
{} |
{"run": <run status or null>}; active run, otherwise first queued run |
simulation.status |
{"run_id": "<run ID>"} |
Run status |
simulation.highscore_snapshot |
{"run_id": "<run ID>"} |
run_id and saved snapshot; includes historical runs in the database |
simulation.highscore_frames |
{"run_id": "<run ID>"} |
run_id and ordered frames for the highest-scoring game of a completed run |
simulation.pause |
{"run_id": "<run ID>"} |
Run status; accepts running or already paused runs |
simulation.resume |
{"run_id": "<run ID>"} |
Run status; accepts paused or already running runs |
simulation.cancel |
{"run_id": "<run ID>"} |
Run status; accepts queued, running, paused, cancelling, or cancelled runs |
simulation.set_move_delay |
{"run_id": "<run ID>", "move_delay_ms": 20} |
Run status; accepts queued, running, or paused runs |
Move delay must be an integer from 0 through 100 in steps of 20 milliseconds.
Cancellation of queued work is immediate. Active work first enters cancelling
and later reaches cancelled. Pause, resume, cancel, and move delay are runtime
controls, separate from the experiment configuration.
Run status contains run_id, state, epochs, completed_epochs, total_steps,
high_score, total_reward, epsilon_injections, last_loss, move_delay_ms,
and last_episode (an episode object or null). error is included when present.
States are queued, running, paused, cancelling, completed, failed,
and cancelled. Status lookup covers runs known to the current server process.
last_episode, when present, contains integer episode, seed, score,
steps, and epsilon_injections; numeric reward and epsilon; loss
(number or null); and string outcome (empty, food, wall, snake,
max_moves, or board_filled). last_loss is also a number or null.
A successful raw response wraps its payload. project_version is the running
server release (DSnakeLab.VERSION), independent of the protocol version:
{
"protocol_version": 1,
"request_id": "request-123",
"status": "ok",
"payload": {"service": "snake-lab", "project_version": "1.6.8"}
}
| Error code | Meaning |
|---|---|
invalid_request |
Invalid envelope, payload fields, or argument values |
unsupported_protocol |
Unsupported control protocol version |
unknown_method |
Method is not supported |
invalid_config |
Configuration failed validation |
run_not_found |
Run is unknown to this server process, or absent from the database for snapshot lookup |
snapshot_unavailable |
Run exists but has no saved snapshot (unfinished, failed, cancelled, or completed before capture was implemented) |
frames_unavailable |
Run exists but has no saved game frames (unfinished, failed, cancelled, or predates game capture) |
invalid_run_state |
Operation is unavailable in the run’s current state |
For invalid requests, request_id may be null if it was unavailable.
Use the same request() method from the example for each operation.
High-score board snapshots
Send simulation.highscore_snapshot with only run_id in the payload.
The server reads the persisted board; no viewer or telemetry subscription is
required. The response uses the normal version-1 envelope. Example payload:
{
"run_id": "example-run-id",
"snapshot": {
"version": 1,
"episode": 3,
"step": 7,
"board": {
"grid_size": [4, 1],
"snake_head": [3, 0],
"snake_body": [[2, 0], [1, 0], [0, 0]],
"food": null,
"direction": [1, 0],
"score": 1
}
}
}
This illustrative board is smaller than the current simulation configuration.
version identifies the snapshot format, independently of the envelope’s
protocol_version. Episodes are numbered from 1; step is the episode’s move
count (0 for the initial-board fallback). Coordinates are zero-based [x, y],
with x increasing rightward and y downward. Body positions are ordered from
neck to tail and exclude the head. direction is [dx, dy]; food is null
when the board is full. Equal high scores select the most recent completed game;
the snapshot is its first board to reach that score.
Clients can call await client.highscore_snapshot(run_id) on AsyncLabClient
to receive the full response envelope. The board object is compatible with
BoardSnapshot.from_dict(). Clients own display and image export; the server
returns raw JSON only. A missing snapshot is not reconstructed or replayed.
Captured high-score games
Send simulation.highscore_frames with only run_id in the payload, or call
await client.highscore_frames(run_id) on AsyncLabClient. The normal response
envelope contains payload: {"run_id": "...", "frames": [...]}. Frames are
available after the simulation completes, including for historical runs after
a server restart. No telemetry subscription is required.
The captured game has the highest final score; ties select the most recent completed game. The server stores its frames and completed status in one transaction before publishing the simulation-ended event.
Each frame has exactly the same format as simulation_runs.high_score_snapshot:
version, episode, step, and board. The board uses the snapshot format
above. Frames are returned in ascending move order, from initial frame 0
through the terminal move, without telemetry sampling.
The server returns the complete game in one response. Clients own replay,
display, and export. Unknown run IDs return run_not_found; runs without a
capture return frames_unavailable. Old runs are not backfilled.