Cappy Adapter Protocol
Version 1. This is the contract a game adapter implements to be driven by Cappy. The schemas in packages/protocol/src/messages.ts (private workspace @uppercut-labs/cappy-internal-protocol) are authoritative; this page explains them.
Transport
- Cappy listens on a loopback WebSocket (
127.0.0.1by default). It refuses to bind anything else. - The game connects as a client. When Cappy launches the game it sets two environment variables:
CAPPY_ENDPOINT: the WebSocket URL, for examplews://127.0.0.1:47100.CAPPY_SESSION_TOKEN: a random per-launch token that the adapter echoes in its hello.
- Every message is one UTF-8 JSON text frame with a
typefield. Binary frames are protocol violations. - A message may not exceed 4 MiB.
Handshake
The adapter's first message is hello:
{
"type": "hello",
"protocol": { "min": 1, "max": 1 },
"token": "<CAPPY_SESSION_TOKEN>",
"adapter": { "name": "cappy-godot", "version": "0.1.0" },
"game": { "id": "fantasy-party", "name": "Fantasy Party" },
"build": "optional build identity",
"capabilities": ["scenarios", "freeform_recording", "replay"]
}
Cappy replies with one of:
{ "type": "welcome", "protocol": 1, "controller": { "name": "cappy", "version": "0.1.0" } }: negotiation succeeded, at the highest shared version.{ "type": "reject", "code": "...", "message": "..." }, followed by a close. Codes includePROTOCOL_UNAUTHENTICATED(wrong token),PROTOCOL_VERSION_INCOMPATIBLE,PROTOCOL_INVALID_MESSAGE,CAPABILITY_MISSING, andOPERATION_BUSY(another adapter is already connected).
Unknown hello fields and unknown capability names are accepted, so newer adapters work with older controllers. A missing capability fails the handshake only when the project requires it.
After welcome, the adapter should send its scenario registry (scenarios).
Cappy may send its first request immediately after welcome, often in the same network read. Adapters must keep reading and processing messages from the moment the socket opens; a message that arrives right behind the welcome must not be dropped.
Operations
At most one operation is active at a time. Cappy creates an operation ID (op), and every message about that operation carries it.
| Operation | Cappy sends | Adapter replies |
|---|---|---|
| Authored scenario | prepare_scenario {op, scenario, parameters, presentation?} |
ready, then after start: started, event*, completed or failed |
| Freeform recording | record_start {op} |
started, event*, then after stop: completed with replay |
| Replay | prepare_replay {op, replay, presentation?} |
ready, then after start: started, event*, completed or failed |
stop{op} asks a running freeform recording or replay to finish.cancel{op} aborts the operation. The adapter should tear down and may stay silent.failed{op, code, message} reports adapter-side failure at any point, for exampleSCENARIO_NOT_FOUND,INVALID_PARAMETERS, orREPLAY_UNSUPPORTED.
Events
{ "type": "event", "op": "op_…", "t": 350, "event": "SPELL_CAST", "durationMs": 120, "payload": { "spell": "fireball" } }
t is operation-relative monotonic milliseconds, never wall-clock time. Cappy stamps each event with its receipt order (seq) and the operation's correlation ID.
t is presented time: what is on screen, which is the recorder's clock. It equals simulation time unless presentation changes the speed. At timeScale 0.5 an event 1000 ms into the simulation is sent with t 2000. An adapter may add the simulation time to the payload as simT.
Presentation
prepare_scenario and prepare_replay may carry presentation, an object from the capture preset. Cappy defines two keys; any others are the adapter's own:
timeScale: a number from 0.1 to 4 (0.5 is half speed). Advertise thetime_scalecapability to accept it.camera: a camera name the adapter defines. Advertisealternate_camerasto accept it. Fail the operation, for example with codeUNKNOWN_CAMERA, for a name you do not have.
Cappy checks those capabilities before recording. A replay played with a presentation must be the same simulation; only what is shown, and when, changes.
Replay payloads
Replay payloads belong to the adapter. Cappy stores and returns them without interpreting them.
- Inline:
{ "kind": "inline", "encoding": "base64", "data": "…", "sha256": "…", "format": "…" }, up to 1 MiB decoded. Cappy verifies the SHA-256. - File:
{ "kind": "file", "path": "…", "sha256": "…", "bytes": 123, "format": "…" }, for larger payloads. Adapter-supplied paths are untrusted and never grant deletion authority.
Scenario registry
{
"type": "scenarios",
"scenarios": [
{
"id": "boss_intro",
"name": "Boss intro",
"parameters": { "difficulty": { "type": "integer", "minimum": 1, "maximum": 3, "default": 2 } },
"requiredCapabilities": ["scenarios"]
}
]
}
Parameter types are string, number, integer, and boolean, with optional required, default, enum, minimum, and maximum. Cappy may request the registry again at any time with list_scenarios.
Liveness
Cappy sends { "type": "ping", "nonce": "…" } every 2 seconds. The adapter must answer { "type": "pong", "nonce": "…" }. If no pong arrives for 6 seconds, the adapter is treated as unresponsive: the active operation fails and the connection is dropped.
Failures
Malformed JSON, schema violations, and messages that do not fit the current operation state fail the active operation with a structured error (PROTOCOL_INVALID_MESSAGE or PROTOCOL_OUT_OF_STATE). Cappy also tells the adapter with { "type": "error", "code", "message", "op"? }. A completed before started can never count as success. A disconnect fails the active operation with ADAPTER_DISCONNECTED.