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 in start_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 · seq 0–3
0map_start1going_live2round_start3player_hurt
batch 2 · seq 4–7
4player_death5round_end6round_mvp7round_complete
arrive on the node meanwhile · seq 8–12
8round_start9…12player_hurt
  1. Batch 1 arrives. The controller stores seq 0–3 and answers a receipt for 0–3. The agent deletes them.
  2. 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.
  3. 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.
  4. 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:

event_batchagent → controller

POST /api/node/events HTTP/1.1
Authorization: Bearer 3f1c…
Ankor-Protocol: <protocol version>
Content-Type: application/json

{
  "allocation_id": "5b1e2c7a-8d4f-…",
  "frames": [
    { "seq": 397,
      "event": { "type": "player_death", … } },
    …
    { "seq": 412,
      "event": { "type": "map_end",
        "winner_team": 1,
        "team1_score": 13, "team2_score": 9,
        … } }
  ]
}

event_batch_receiptcontroller → agent

HTTP/1.1 200 OK
Content-Type: application/json

{
  "rejected": []
}

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:

  1. Authenticate. Answer 401 for an unknown bearer token. Answer 426 when Ankor-Protocol is missing or names an incompatible version.
  2. Check the batch. Refuse it if the body does not parse with EventBatch from @ankorcs/protocol. It fails to parse when it does not name a server or its frames are not consecutive.
  3. Find the server and its match. The batch’s allocation_id must name a server that your controller launched on this node. That launch’s match is the batch’s match. Parse each frame with EventBatchFrame. 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.
  4. Walk the frames. Keep a cursor per server: the highest seq handled so far.
    • A seq at or below the cursor is a duplicate. Check that it matches the stored event, and store nothing new.
    • A seq above 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 type your protocol version does not have comes from a newer plugin. Move the cursor past it, and store or quarantine nothing. Versioning explains why.
    • player_connect with rejected set 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.
  5. Commit, then answer. Store the events, the quarantined frames and the cursor in one transaction. Then answer with a 2xx status and a receipt for the whole batch. If you cannot, for example because storage is down, answer with a 5xx. 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 their round where they carry one, not by arrival.
  • round_complete follows every event of its round.
  • A grenade is spread over several events. grenade_throw gives it a grenade_id that is unique within the server. The events it causes later carry that id, up to its grenade_end.
  • A null measurement means the plugin could not read it. Keep it missing, not zero.
  • After a match_restored, the events from the next round_start on replace what the server sent for that round and the rounds after it.

Edit this page