Protocol
Event uploads
Statistics are built from gameplay events: kills, damage, grenades, rounds and the final score.
The plugin reports them to the agent, which uploads them to your controller in batches. You need
this endpoint when you launch matches that send statistics
(match.send_statistics).
A match’s phase and score go over the socket instead, as
Get the result describes. A match without statistics sends its backups
there too, as backup. The protocol reference has the batch body, the
answers and every event type.
Event identity
Every event has an identity of two parts:
allocation_id: the server that sent it, named by the allocation you gave it instart_server. The allocation names one server, because a node refuses to launch an allocation it has launched before. A batch carries the events of one server and names it once for all of them.seq: the event’s position among that server’s events, counting from 0 without gaps.
Server 0199a0b4-…-abc1one allocation, numbering its events from 0
- Batch 1 arrives. The controller stores seq 0–3 and answers a receipt for 0–3. The agent deletes them.
- Batch 2 arrives. The controller stores seq 4–7, but the receipt is lost on the way back. The agent keeps them, and seq 8–12 arrive on the node meanwhile.
- After its backoff, the agent sends seq 4–12 in one batch. The controller already has seq 4–7 of that server, so it stores 8–12 and answers a receipt for 4–12.
- Uploads continue from seq 13.
Each attempt is rebuilt from the oldest event that no receipt covered. It carries as many of the
server’s events as fit, up to 128 events and
2 MiB. A retry therefore starts at the same seq as the attempt that
failed, and it can carry more events. The events it repeats are the same as those you stored,
because a server’s events never change once the node has them.
Events survive an agent restart or a lost connection. They stay on the node until a receipt covers them. The node keeps a server’s events for 7 days after its last event or receipt, so that it recognises a replay of delivered events. Events that no controller took in that time are dropped, and the agent logs how many.
The agent backs off every server when no answer comes, or when your controller answers that it is unavailable or refuses the node. When you refuse one server’s batch, the agent backs off only that server. The node’s other servers keep uploading.
POST <events path>
The agent posts to controller.events_path, /api/node/events by default. Each request carries
the agent’s headers and a JSON body for one server. The body holds the
server’s allocation_id and 1–128 frames of its events, with
consecutive seq. The frames come to at most 2 MiB together, so
accept request bodies of that size plus the wrapper. Each event carries the fields of its
gameplay event, cut short here, and the last batch of a map ends with map_end:
Answers
Your controller answers each batch in the HTTP response. Uploads stay off the WebSocket, apart from
event_backlog. That message carries the node’s whole backlog of stuck events, and the agent
sends it when the backlog changes. Replace your stored backlog with it. hello carries the
same backlog in its event_backlog.
Agent
Controller
POST /api/node/eventsAuthorization: Bearer <node token>, Ankor-Protocol{ "allocation_id": …, "frames": [ seq 128 … 255 ] }
The controller answers in the same HTTP response, with one of:
2xx batch stored{ "rejected": [] }
The agent deletes seq 128–255 and sends the next batch.
Anything else, or no answer within 15 secondsbody ignored, except a 409's detail, which is logged
The agent keeps the batch, backs off from a second up to 30 seconds, reports event_backlog over the WebSocket and retries.
| Status | Body | The agent |
|---|---|---|
2xx |
EventBatchReceipt |
Deletes the whole batch. It never resends frames listed in rejected, because they would fail the same way. A body that is not a valid receipt counts as a refusal of that server’s batch. |
401, 403, 408, 429, 502, 503 or 504 |
ignored | Keeps the batch, pauses every server’s uploads, backs off and retries. No answer within 15 seconds counts the same. |
| any other status | ignored, except a 409’s detail, which is logged |
Keeps the batch and backs off only that server. The other servers keep uploading. |
Receipt
A receipt covers the whole batch. Normally you keep every frame, and rejected is empty. If you
cannot use a frame, for example because its event names another match, quarantine it and list it in
rejected. Quarantining sets a frame aside with a reason instead of counting it. For a batch where
one event was set aside:
{
"rejected": [{ "seq": 140, "reason": "unknown weapon" }]
}
Refusing a batch
A batch that cannot be trusted points to a bug or to damage in transit, because the agent checks
every batch before sending it. To make the cause visible, refuse the batch with 409 and this
body. Its detail shows up in the agent’s log and in event_backlog:
{
"code": "event_integrity_error",
"detail": "frames 3 and 4 are not consecutive"
}
Handling a batch
You decide what your controller does with the frames. If you keep statistics, the steps below keep them consistent however often a batch is retried. You can store, log or drop quarantined frames.
For each request:
- Authenticate. Answer
401for an unknown bearer token. Answer426whenAnkor-Protocolis missing or names an incompatible version. - Check the batch. Refuse it if the body does not parse with
EventBatchfrom@ankorcs/protocol. It fails to parse when it does not name a server or its frames are not consecutive. - Find the server and its match. The batch’s
allocation_idmust name a server that your controller launched on this node. That launch’s match is the batch’s match. Parse each frame withEventBatchFrame. If the server or the match is not yours, quarantine every frame and still answer with a receipt, so that the node deletes them. A retry cannot fix that batch. - Walk the frames. Keep a cursor per server: the highest
seqhandled so far.- A
seqat or below the cursor is a duplicate. Check that it matches the stored event, and store nothing new. - A
seqabove the cursor is new. Store the event if it parses, belongs to the batch’s match and names only players you launched the match with. Otherwise quarantine it with a reason. Either way the cursor moves past it, so one bad event never delays the rest. - An event whose
typeyour protocol version does not have comes from a newer plugin. Move the cursor past it, and store or quarantine nothing. Versioning explains why. player_connectwithrejectedset is the one event that names an outsider by design. It means the plugin kicked that player.- A jump past cursor + 1 is a gap. Log it and carry on. The agent skips numbers only when it lost them, as when an operator deleted its event store.
- A
- Commit, then answer. Store the events, the quarantined frames and the cursor in one
transaction. Then answer with a
2xxstatus and a receipt for the whole batch. If you cannot, for example because storage is down, answer with a5xx. The agent backs off and tries again.
Reading the events
- Events can arrive after their round or after the server has ended. Place them by their
tick, and theirroundwhere they carry one, not by arrival. round_completefollows every event of its round.- A grenade is spread over several events.
grenade_throwgives it agrenade_idthat is unique within the server. The events it causes later carry that id, up to itsgrenade_end. - A
nullmeasurement means the plugin could not read it. Keep it missing, not zero. - After a
match_restored, the events from the nextround_starton replace what the server sent for that round and the rounds after it.