Architecture · Coding guidelines

Snake Lab exposes three ZeroMQ interfaces for downstream systems. Install pyzmq for Python clients and replace wintermute with your server hostname.

Interface Default endpoint Client socket Reference
Control tcp://wintermute:41970 REQ Control protocol
Events tcp://wintermute:41972 SUB Event protocol
Telemetry tcp://wintermute:41971 SUB Live telemetry

These defaults come from DSnakeLab.CMDB_NETWORK. Production overrides and CLI arguments may change them; see network configuration. Local Python integrations can use Network.destination("control"), Network.destination("telemetry"), or Network.destination("events") from snakelab.Network to resolve each address and port from the same settings.

  1. Establish an event subscription before submitting work.
  2. Call simulation.submit on the control port with a configuration object.
  3. Save the returned run_id; the reply confirms the run is queued.
  4. React to simulation_ended events whose payload.run_id matches that run.

The event is an asynchronous completion result. Delivery is best effort, with no acknowledgement or replay; use simulation.status to reconcile missed notifications while the run is known to the server. Every submission creates a new run, including retries after a timeout.

Control and telemetry use protocol version 1. Events use their independently versioned protocol, currently version 2.

Live Telemetry

Live telemetry is published on tcp://wintermute:41971. Connect a ZeroMQ SUB socket and subscribe to one or more topics:

  • snake_lab.run
  • snake_lab.episode
  • snake_lab.frame

Per-move frames are built and published only while a subscription matches snake_lab.frame. Opening lab-client during a run enables streaming on subsequent moves; closing the last viewer disables it once ZeroMQ reports the disconnection. Prefix subscriptions such as snake_lab. and the empty filter (all topics) also enable frames. Subscribing only to run, episode, or completion events does not enable them. Subscription and disconnect detection are asynchronous; joining a paused run provides a fresh frame on its next move. Run/episode telemetry, stored results, and completion events continue independently. No client change or simulation configuration option is required.

Each publication is a two-part message: the UTF-8 topic followed by a JSON envelope containing protocol_version, sequence, run_id, and payload. Subscribe before submitting a run when the initial lifecycle events are needed. Telemetry is best effort: queue overflow drops the oldest pending message, regardless of topic, and shutdown discards pending telemetry. Sequence numbers increase per topic across runs within one server process; they cannot reveal messages dropped before publication. Use control status for current progress and the database for stored results.

The project’s control client and telemetry client are the reference implementations.

High-score Board Snapshots

Every successfully completed simulation saves one board: the first position where it achieved its final high score. Each episode retains its highest-scoring immutable board by reference, then replaces the simulation’s best board only if its completed score is at least as high. Ties select the most recent game. A simulation that never scores saves the last completed episode’s starting board.

Capture works without a viewer or telemetry subscription. There are no board copies or database writes in the capture hot loop. The server saves the winning snapshot with the final score and completed status before publishing simulation_ended. Failed and cancelled simulations do not save snapshots.

Request a saved board on the control endpoint using a REQ socket:

{
  "protocol_version": 1,
  "request_id": "snapshot-1",
  "method": "simulation.highscore_snapshot",
  "payload": {"run_id": "<run ID>"}
}

The successful response has status: "ok" and a payload containing run_id and snapshot. The snapshot fields are:

Field Meaning
version Snapshot format version, currently 1; separate from the control protocol version
episode Episode number, starting at 1
step Move number within the episode; 0 for a starting-board capture
board.grid_size [width, height]
board.snake_head Head coordinate [x, y]
board.snake_body Coordinates ordered from neck to tail, excluding the head
board.food Food coordinate, or null when the board is full
board.direction Direction vector [dx, dy]
board.score The simulation’s final high score

Coordinates are zero-based, with x increasing rightward and y downward. The board object can be passed directly to BoardSnapshot.from_dict(). Clients own rendering and image export; the server returns raw JSON, not SVG or PNG. See the control protocol for an example response payload.

Python clients using the project’s AsyncLabClient can request the full response envelope with:

response = await client.highscore_snapshot(run_id)
if response["status"] == "ok":
    snapshot = response["payload"]["snapshot"]
    board = snapshot["board"]
    # Render or export the board in your client.
else:
    code = response["error"]["code"]
    # Handle unavailable snapshots separately from unknown run IDs.

Lookup reads the database, so saved snapshots remain accessible after server restarts. Existing runs retain their scores and configurations but have no snapshot if they completed before capture was implemented. The server does not replay simulations or reconstruct missing boards.

Error code Meaning
run_not_found The run ID does not exist in the database
snapshot_unavailable The run exists but has no saved snapshot, including old, unfinished, failed, or cancelled runs
invalid_request The payload must contain only a non-empty string run_id

Existing clients can continue using the control and event protocols without requesting snapshots. An optimizer such as AX3L can request a snapshot using the run ID it selects as its golden configuration; Snake Lab does not select the golden configuration itself.

Simulation Execution

Game logic, policy inference, replay storage, and training run on CPU. The simulator uses the original Python game rules, Python exploration generator, and NumPy replay batches. Episodes remain serial with one training attempt after each completed episode when a batch is available. CUDA availability does not change the selected device.

Model, exploration, replay, and episode seeds are derived separately from the configured master seed. Identical results across hardware or library versions are not guaranteed.

Replay and Training

Replay stores complete episodes and samples fixed-length sliding windows uniformly across all eligible windows, without replacement within a batch. Windows never cross episode boundaries. Longer games contribute more windows; a batch can contain multiple windows from the same game.

The defaults are training.batch_size: 24 (windows) and training.sequence_length: 8 (moves). Training starts when enough windows exist for a batch; there is no minimum episode count. Each sampled window contributes its final move as the loss target. Terminal moves are included when their ending windows are selected.

Episodes shorter than sequence_length contribute no windows. Replay evicts whole oldest episodes to stay within training.replay_max_frames. Completed episodes share one contiguous array for current and next observations; sampled batches reuse buffers and must be consumed before the next sample.

The current configuration schema defines tunable ranges and fixed settings. training.replay_min_episodes is not supported.

Configuration Queries

The configurations table stores one row per accepted run, linked to simulation_runs.run_id. Its 26 numeric columns follow the configuration schema, replacing dots with underscores: training.learning_rate becomes training_learning_rate, and game.rewards.food becomes game_rewards_food. seed uses BIGINT UNSIGNED; epoch counts, dimensions, lengths, and model sizes use INT UNSIGNED. Rewards and floating-point settings use DOUBLE, including rewards constrained to integers by configuration schema v2. Configuration and run creation commit together. Configuration values are stored only in configurations.

Repeated configurations have separate rows for each run. Join to run status when searching completed experiments:

SELECT c.training_learning_rate, c.model_hidden_size,
       COUNT(*) AS completed_runs, MAX(r.high_score) AS best_score
FROM configurations AS c
JOIN simulation_runs AS r ON r.run_id = c.run_id
WHERE r.status = 'completed'
GROUP BY c.training_learning_rate, c.model_hidden_size;

See the changelog for the one-time v0.13.0 clean database setup.

Schema snakelab/schemas/database-v3.sql only creates the new table. It can be reapplied safely and does not backfill historical runs or delete data. New accepted runs receive configuration rows immediately, regardless of their eventual status. Deleting a run cascades to its configuration row.

Schema snakelab/schemas/database-v4.sql adds nullable JSON column simulation_runs.high_score_snapshot for one saved board per run. It can be reapplied safely without deleting existing data or backfilling old snapshots. NULL means no snapshot is available. See High-score Board Snapshots for capture behavior and the retrieval API.

Schema snakelab/schemas/database-v5.sql adds simulation_high_score_frames, with one JSON frame per (run_id, step). It includes the initial board and every move of the most recent game tied for the run’s highest score. Capture retains immutable states in memory during simulation and saves only the final winner, atomically with completion. Deleting a run cascades to its frames. The migration is safe to reapply and does not backfill old runs.

Retrieve frames over ZMQ with simulation.highscore_frames and {"run_id": "..."}, or await client.highscore_frames(run_id). The response payload contains run_id and frames in move order. Availability is guaranteed after completion. See Captured high-score games for frame fields and errors.

scripts/upgrade.sh applies the migration while the service is stopped, before deploying and starting the application. To apply the schema separately:

sudo systemctl stop snake-lab.service
sudo scripts/apply-database-schema.sh
# Restart after the schema command succeeds.
sudo systemctl start snake-lab.service

Run venv/bin/python scripts/run-database-tests.py to create a disposable local MariaDB instance and run the database tests. It requires MariaDB server tools and permission to open local sockets. Alternatively, set SNAKELAB_TEST_DB_SOCKET to an isolated test server’s Unix socket; the tests use passwordless root access and create and drop randomly named test databases.

Live Client

The live client uses the legacy three-column dashboard. Game Score retains the latest 200 received episodes in a deque, with the legacy smoothing window of max(1, retained_count // 40) (five points when full). The record plot retains the latest 500 received episodes. Loss retains all received non-null losses for the current run and plots averaged bins of max(1, loss_count // 75) samples, using the first episode in each bin, as in the legacy client. The Score Distribution tab uses a Textual Plot histogram to count how often each score occurs across all received episodes in the current run. Counts reset for each new run; no plot history is fetched. Smaller terminals can scroll the dashboard.

See Installation for client startup and database credentials.

Development setup

./scripts/rebuild-venv.sh
venv/bin/python -m unittest discover -s tests

Run a development server without MariaDB in one terminal:

venv/bin/python -m snakelab.server \
    --address 127.0.0.1 \
    --log-file /tmp/snake-lab.log \
    --ephemeral

Start the development client in another terminal:

./client/lab-client.sh