Protocol
A minimal controller
These steps take one node from a new token to a running CS2 server on it. The
protocol overview explains each exchange, and this page puts them in order. The
examples use cs.example.com for your controller.
Talking to the agent
The protocol overview describes how to accept a node’s connection and exchange messages
with its agent. In Node.js, the @ankorcs/protocol package holds the message schemas. Its session,
@ankorcs/protocol/session, runs the exchanges for you.
Agents may run a newer protocol version than your controller. Versioning lists what your controller must accept from them, and what it skips.
Setup
1. Create the node and its token
Generate a node token. Show the token to the operator once and store only its SHA-256.
2. Give the operator the setup command
The setup script is served from ankor.sh for every controller. --controller
points it at yours:
curl -fsSL https://ankor.sh/node-setup.sh | bash -s -- --controller https://cs.example.com
The operator also opens the game ports of the slots you add in step 5. Install a node has the step.
3. Answer the setup request
The script posts the token to your setup endpoint. Look the token up by its hash.
Answer with the node’s agent.yml, and put in it the token that the request carried, since you
store only the hash. slots may be empty:
token: <the token from the request>
controller:
host: cs.example.com
tls: true
cs2_image: ghcr.io/ankorcs/ankor-cs2:0.9
agent_image: ghcr.io/ankorcs/ankor-agent:0.9
slots: []
In TypeScript, renderAgentYml from @ankorcs/protocol/agent-yml writes this file.
Without the endpoint, the operator writes this file before running the script.
Write agent.yml yourself has the steps.
4. Accept the socket and welcome the node
The agent dials controller.socket_path with its token and its
protocol version. Accept the upgrade only when
both check out, then answer its hello with welcome. With @ankorcs/protocol and the
ws package:
import { createHash } from 'node:crypto';
import { WebSocketServer } from 'ws';
import { PROTOCOL_HEADER } from '@ankorcs/protocol';
import { bearerNodeToken, isCompatibleProtocol, protocolVersionOf, upgradeRefusal } from '@ankorcs/protocol/helpers';
import { createAgentSession } from '@ankorcs/protocol/session';
const sockets = new WebSocketServer({ noServer: true });
server.on('upgrade', (request, socket, head) => {
const token = bearerNodeToken(request.headers.authorization);
const node = token === null ? null : findNodeByTokenHash(createHash('sha256').update(token).digest('hex'));
if (node === null) {
socket.end(upgradeRefusal(401));
return;
}
const protocol = request.headers[PROTOCOL_HEADER.toLowerCase()];
if (!isCompatibleProtocol(protocol)) {
socket.end(upgradeRefusal(426));
return;
}
// The node's version, to offer it only the features it has.
node.protocol = protocolVersionOf(protocol);
sockets.handleUpgrade(request, socket, head, (ws) => run(node, ws));
});
function run(node, ws) {
const session = createAgentSession({ send: (text) => ws.send(text) });
ws.on('message', (raw) => {
const frame = session.receive(raw);
if (frame.kind === 'report' && frame.message.type === 'hello') {
saveHello(node, frame.message);
session.notify({ type: 'welcome' });
}
// Skip `unsupported`, a message from a newer agent.
});
ws.on('close', () => session.closed());
}
5. Add slots
With no slots in agent.yml, send set_config with each slot’s ports and its own
Game Server Login Token. slots is the whole layout:
{
"type": "set_config",
"id": "r1",
"config": {
"slots": [
{ "id": "s1", "port": 27015, "tv_port": 27020, "gslt": "<GSLT>" }
]
}
}
The reply carries the node’s settings with the new slots. An slot_status follows for each
new slot. With the session:
const result = await node.request({ type: 'set_config', config: { slots } });
if (result.status === 'ok') {
saveSettings(result.data);
}
A controller can create the tokens with a user’s Steam Web API key.
IGameServersService
CreateAccount, with appid 730 and a memo, returns the login_token. GetAccountList,
ResetLoginToken and DeleteAccount manage tokens later. ankor.sh
creates its tokens this way.
6. Install CS2
Send update_cs2 with mode update, then wait until cs2.update in node_status
has an outcome. The install can take tens of minutes. Install CS2
explains the reports.
7. Start a server
Store a new allocation id, then send it in start_server. Keep track of every
server explains why. This launch leaves out every field that has a
default. It therefore has no match, and runs a plain CS2 server on de_dust2 in competitive
mode. Ankor’s plugin runs no match. map_name, exec, commands and the node’s own plugins set
the game. ServerLaunchConfig lists the defaults.
The agent replies with the allocation and the slot it picked.
{
"type": "start_server",
"id": "r3",
"allocation_id": "e7a3d9c1-6b2f-4e8a-9c5d-1f0b3a7e2d64",
"launch": {
"hostname": "Ankor",
"map_name": "de_dust2",
"max_players": 10,
"idle_shutdown_seconds": null,
"exec": [],
"commands": []
}
}
8. Hand out the address
Wait for the slot’s slot_status with state running. Players connect to the node’s
address and the slot’s port, both from the latest hello. The address is
config.pinned_ip, or public_ip when none is pinned. A public_ip of null means the node found
no public address, so set pinned_ip with set_config first. A player then connects with:
connect 203.0.113.7:27015
stop_server ends the server. To run matches with statistics, launch with a
MatchConfig whose send_statistics is true, and accept
event uploads.
Keep track of every server
Launches and stops race with the node’s reports:
- A reply may never come.
- A node may reconnect mid-launch.
- A stop may arrive before the launch it ends.
Store each server’s state and update it only from the node’s reports. A stop is done once the node confirms it.
Launches
- Pick a new
allocation_idfor every launch and store it before you sendstart_server. - Rank nodes by their latest reports with
slotTakesLaunchandcs2Behindfrom@ankorcs/protocol/helpers. The node still decides, and may refuse. - The server is running once the reply or a
slot_statusnames its slot, whichever comes first. - A
StartRefusalmeans no server started. Try the next node. allocation_in_useanswers astart_serversent again with the sameallocation_id. It means the first launch is starting or running on that node. Wait for itsslot_statusinstead of retrying on a different node.- Any other error, or no reply at all, may leave a server running, because the node’s reports can
arrive after the reply would have. Stop it on that node before you launch it anywhere else. With
the session, no reply is a
timeoutorclosedresult.
Stops
- A stop may be sent while the server’s launch is still waiting for its reply. The node then ends the launch after its current step, without starting the server. The launch’s reply is an error. If the stop arrives while Docker starts the container, the launch succeeds and the server is then stopped.
- The reply to a stop comes within 10 seconds for the stop and 35 seconds more. With the session, the stop waits that long whatever wait the session or the call sets.
unknown_allocationmeans no server runs under the allocation.failedleaves the server running. Send the stop again.
Disconnects and restarts
- A disconnected node may still run its servers. Keep their records until the node reconnects.
- Compare each
hellowith your records. A server that thehellono longer lists has ended. A launch whose reply never came is running if thehellolists it. Stop a server that you have no record of. server_exitsin thehellogives the reason for each server that ended while the node was away. A server with no entry there, and noserver_exitedafter thewelcome, ended without the agent knowing why.- An exit after the agent sent the
helloarrives asserver_exitedafter thewelcome. It can repeat what thehelloalready told you. - Keep one socket per node.
Logs
- Send
watch_logsfor a source while someone is looking at it. Send it again before 2 minutes pass to keep the source streaming. Each source needs a separate request. - Ask for a replay with a
replay_idwhen a viewer opens a source. Apply eachlog_batchwithwithLogBatchfrom@ankorcs/protocol/helpers. A batch withresetreplaces the lines you hold. - A line can carry ANSI colour escapes (SGR, such as
ESC[32m), as the server printed them. Render them as colours, or strip eachESC[…mto show plain text. The agent has already removed every other escape and control character, so a line cannot move a cursor, open a link or write to a clipboard. A line cut at the length limit can end in part of an escape; drop that part.LogLinehas the details. log_indexlists every source the node keeps. A source missing from it no longer exists on the node.- A
console_commandis answered once the server’s plugin has handed the command to the engine. That answer says nothing about whether the command worked. The command’s output arrives as log lines of that server, so watch the server’s log to show it.
Browsers
- Push every change to an allocation or a node to every connected browser over a WebSocket. Push it from your stored state. The reply to a launch or stop request says only that the request was accepted. The outcome comes later, from the node, a deadline, or another tab or player.
- Send the whole state when a browser connects, since it missed every change in between.
Events
- A batch arrives again when its receipt is lost, and can arrive after its server has ended. Handling a batch keeps statistics consistent through both.
Replace a slot’s token
When Steam refuses a slot’s login token, the slot receives no launches until you replace the token. Replace it once the slot is idle, because revoking the token of a running server drops that server.
- Send
reserve_slot. From the reply on, the agent starts no server in the slot, so none starts on a token Steam is about to drop. A slot running a server cannot be reserved. - Revoke the old token at Steam and obtain a new one.
- Send the new token with
set_config, which ends the reservation. If no new token follows, the reservation runs out.
The agent logs the new token on immediately and reports whether Steam accepted it.
One instance or several
Run your controller as one instance. Add instances only when one process can no longer serve your nodes and viewers, because several instances turn simple in-memory state into distributed state.
Each node keeps one socket open to the controller. Much of what the protocol asks of you is state about that socket:
- the requests waiting for a reply
- the slots and servers that the node last reported
- which socket owns the node.
In one process, that state is plain memory. Apply each node’s updates one at a time, in the order
its messages arrive, so two updates to a node never interleave. A single reader per socket or a
lock per node does that in any language. A restart costs little, because every agent redials and
its hello rebuilds what the instance knew.
Several instances behind a load balancer each hold some of the sockets. A request for a node has
to reach the instance that holds the node’s socket. An example is a launch from a user whose
browser reached another instance. That routing needs a message bus to coordinate the instances,
such as NATS with JetStream, Redis or Valkey streams, Kafka, RabbitMQ or Postgres
LISTEN/NOTIFY. Each piece of per-node state also needs one owner:
- Socket ownership. A node that reconnects can land on another instance while its old socket
is still open on the first. Record which instance holds each node, with an expiry. Close a
second socket with
node_connected, as the protocol asks. - Requests and replies. A reply arrives on the instance that holds the socket, matched by
reply_to. A caller on another instance waits for it over the bus. When the holding instance dies, the caller receives no reply. Treat that as a launch or stop that may have run. - Decisions about servers. Choosing a node for a match and recording the launch has to happen once. Two instances that act on the same match can launch it twice, so route each match’s decisions through one queue or lock.
- Event uploads. Batches are plain HTTP and can reach any instance. Lock the per-server cursor
from Handling a batch inside its transaction, for example with
SELECT … FOR UPDATE. Two batches of one server then cannot both advance it. - Live views. Slot statuses, resources and logs arrive on the instance that holds the socket. Fan them out to viewers on the other instances over the bus.
Pros
- Rolling updates. A deploy replaces one instance at a time. The controller stays up, and only that instance’s nodes redial.
- Partial outages. A crashed instance drops only its share of the sockets.
- More capacity. The controller grows past what one process can hold.
Cons
- A bus to run. The controller depends on one more system, and on its delivery guarantees.
- Distributed state. Every rule above has to hold across instances. A missed lock or a lost bus message shows up as a launch that ran twice or a reply that never came. Such failures are hard to reproduce.