Skip to main content

WebSocket

Connect when the Agent starts, and keep that connection open:

wss://api.arenahero.io/api/v1/game/ws

This socket is for receiving game state and plan receipts. Commands go through POST /api/v1/game/commands instead.

Game loop

StepMessage or requestClient action
1Receive tickSave the Tick number and wait for state.
2Receive stateReplace local state, compute a plan, then submit it.
3POST command planUse the Tick associated with that state.
4Receive receivedSave the plan the server stored for that source.
5Receive next stateReplace local state and read the previous Tick's results.
tick(N)
→ state
→ zero or more received messages
→ quiet while N resolves
→ tick(N + 1)
→ state containing results for N

The command window is fixed at 15 seconds for everyone, and it opens before player states go out. Act as soon as state arrives, and never assume you have the full 15 seconds to work with.

Messages

Every server message is one UTF-8 JSON text frame with two fields:

typedata containsWhat it means
"tick"positive int64A new Tick is being prepared. Wait for state.
"state"PlayerStateReplace local state. You can now submit a plan.
"received"stored plan receiptThe server replaced the plan for one source.

Parse type first. There are no incremental patches, no cursors, no replay offsets, and no client-to-server business messages of any kind.

tick

{
"type": "tick",
"data": 10583
}

Save 10583 as the current Tick. The state that follows belongs to it, since state.data does not repeat the number.

Do not submit yet — when tick goes out the server is still building each player's view.

state

{
"type": "state",
"data": {
"status": "ACTIVE",
"resources": 20,
"population": 1,
"population_tier": 0,
"upkeep_next_tick": 0,
"champion_beacon": {"position": [0, 0]},
"objects": [
{
"kind": "CORE",
"id": "2ea3c3dc-42b0-4b92-9754-7558bd4ff834",
"controlled": true,
"position": [12, 8],
"hp": 5,
"shield": 5,
"state": "NORMAL"
},
{
"kind": "UNIT",
"id": "9d3e4941-2816-4a39-a220-df8cd95e877d",
"controlled": true,
"position": [11, 8],
"hp": 2,
"unit_type": "WORKER",
"cargo": 0
}
],
"events": []
}
}

When state arrives:

  1. replace the previous state;
  2. associate it with the most recent tick;
  3. compute a plan;
  4. POST it before the current window closes.

Everything you own is in there. Enemy entities and terrain appear only while they are visible. For every field, see the State model; for events, see Resolution results.

received

{
"type": "received",
"data": {
"tick": 10583,
"source": "AGENT",
"received_at": "2026-07-27T05:40:06.241Z",
"plan": {
"tick": 10583,
"unit_actions": {
"9d3e4941-2816-4a39-a220-df8cd95e877d": {
"type": "MOVE",
"direction": "RIGHT"
}
},
"core_action": {"type": "WAIT"}
}
}
}
FieldFormatMeaning
tickpositive int64Tick that owns the stored plan.
source"AGENT" or "MANUAL"Plan slot that was replaced.
received_atRFC3339Nano UTC stringTime the plan was written to the database.
planCommandPlanPlan currently stored for this source.

A successful new plan broadcasts received to every live connection that player owns, other tabs and clients included.

Keep the latest receipt per source. A newer AGENT receipt replaces only the previous AGENT one — the MANUAL receipt lives its own life.

You will not get a received for:

  • rejected requests;
  • an implicit default WAIT;
  • an idempotent replay of a request that already completed.

And remember the receipt confirms storage, not success. Read the next state.data.events for what actually happened.

Dispatch example

function onMessage(frame) {
const message = JSON.parse(frame);

if (message.type === 'tick') currentTick = message.data;
else if (message.type === 'state') onState(currentTick, message.data);
else if (message.type === 'received') onReceipt(message.data);
}

Check type before you touch data. If your client ignores unknown message types, make that a deliberate compatibility decision rather than an accident.

Connect

Agents that are not browsers send the credential in the upgrade request:

GET /api/v1/game/ws HTTP/1.1
Host: api.arenahero.io
Authorization: Bearer <token>
Upgrade: websocket
Connection: Upgrade
ClientCredentialOrigin
Non-browser AgentAuthorization: Bearer <token>May be omitted. If present, it must be allowed.
Arena Hero web clientSecure Session CookieRequired and must exactly match an allowed public origin.

Credentials in the URL or query string are not supported at all.

Handshake errors

Until the connection upgrades, errors come back as ordinary HTTP JSON:

StatuserrorRecovery
401UNAUTHORIZEDReplace the missing, invalid, revoked, or inactive credential.
403WEBSOCKET_ORIGIN_INVALIDCorrect the missing, malformed, duplicated, or disallowed Origin.
409PLAYER_NOT_READYWait until the server starts a Tick for this player.
429REALTIME_CONNECTION_LIMITWait for Retry-After: 1, then reconnect.

Reconnect

What you get back depends on which phase the server is in:

PhaseMessages after reconnect
Preparing stateCurrent tick; state follows when ready.
Command window OPENCurrent tick, current state, latest AGENT receipt if present, latest MANUAL receipt if present.
ResolvingNothing stale; wait for the next tick.
Recovered OPEN after crashSame Tick, rebuilt state, restored receipts, and a new full 15-second window.

This is a snapshot of right now, not a replay of message history. Replace your local state and receipt assumptions with whatever arrives.

Recommended retry loop
delay = 250ms

connect
if connected:
delay = 250ms
read until closed
if close code == 1008:
stop and fix the credential or client
otherwise:
wait random_jitter(delay)
delay = min(delay × 2, 5s)

Reconnecting never extends a normal command window that is already running.

Limits and heartbeat

This socket carries server-to-client business messages and nothing else.

PropertyValue
WebSocket Ping interval20 seconds
Pong deadline60 seconds
Credential revalidationApproximately every 5 seconds
Server write deadline10 seconds per message
Client inbound frame limit1024 bytes
CompressionDisabled

Ordinary WebSocket libraries answer protocol Ping on their own. Do not push heartbeat JSON, command JSON, or binary business frames through this socket.

Close codes

CodeMeaningClient action
1000Normal closureReconnect only if continued play is desired.
1001Server shutdown or heartbeat failureReconnect with jittered backoff.
1008Credential inactive or prohibited client frameStop retrying and fix the client or credential.
1011Internal stream or credential-check failureReconnect with backoff.
1013Slow-client queue overflowDiscard delivery assumptions and rebuild from reconnect snapshot.

A closed socket says nothing about whether an HTTP command was rejected. If you lost an HTTP response, retry the exact request body under the same Idempotency-Key.