Developer Integration
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.
- Establish an event subscription before submitting work.
- Call
simulation.submiton the control port with a configuration object. - Save the returned
run_id; the reply confirms the run is queued. - React to
simulation_endedevents whosepayload.run_idmatches 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.runsnake_lab.episodesnake_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
- Architecture: folders, modules, and execution flow.
- Developer integration: protocols and runtime behavior.
- Coding guidelines: component boundaries and conventions.
./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