Command API
Send one plan after each state message:
POST /api/v1/game/commands HTTP/1.1
Host: api.arenahero.io
Authorization: Bearer <token>
Idempotency-Key: agent-10583-plan-01
Content-Type: application/json
{
"tick": 10583,
"unit_actions": {
"9d3e4941-2816-4a39-a220-df8cd95e877d": {
"type": "SHOOT",
"target_id": "175f47f4-f7de-4785-b45c-9a2d2289a8ea",
"expected_cell": [120, 85]
}
},
"core_action": {
"type": "SPAWN",
"unit_type": "VANGUARD"
}
}
An Agent request replaces that player's current AGENT plan. Wait for the state
for that Tick before sending it.
Headers
| Header | Required | Format | What it does |
|---|---|---|---|
Authorization | Yes | Bearer <token> | Identifies the Agent. |
Content-Type | Yes | application/json | charset=utf-8 and other parameters are allowed. |
Idempotency-Key | Yes | 8-128 bytes in ASCII 0x21-0x7e | Identifies this request and its exact body. |
The maximum body size depends on the deployment. Go over it and you get
413 REQUEST_BODY_TOO_LARGE.
Plan body
| Field | JSON type | Required | What to send |
|---|---|---|---|
tick | integer | Yes | The positive int64 from the latest tick message. |
unit_actions | object | No | Unit UUIDs mapped to actions. Use {} when no Unit acts. |
core_action | object | No | One Core action. Omit it when the Agent has no Core action. |
Note that unit_actions is an object, not an array. Every key has to be the
lowercase, hyphenated UUID of a living Unit you own, and you must never emit
duplicate JSON keys.
A POST replaces the earlier plan
Say the stored Agent plan currently reads:
Unit A: MOVE
Unit B: HARVEST
and the next body contains only:
Unit A: WAIT
The stored plan is now WAIT for Unit A and nothing at all for Unit B. Unit B
resolves to WAIT too, unless Manual supplies an action, because the server does
not carry missing actions over from the previous Agent plan.
Unit actions
Read type first, then send only the fields shown in that row.
type | Unit | JSON | What happens during resolution |
|---|---|---|---|
WAIT | Any | {"type":"WAIT"} | The Unit does nothing. |
MOVE | Any | {"type":"MOVE","direction":"RIGHT"} | The Unit tries to move one cardinal cell. |
HARVEST | Worker | {"type":"HARVEST"} | Loads 1 resource, or 2 while the player holds the Beacon. |
DEPOSIT | Worker | {"type":"DEPOSIT"} | Moves all cargo into the player's Core on the same cell. |
SWEEP | Vanguard | {"type":"SWEEP","direction":"UP"} | Deals 1 damage to each enemy entity in the adjacent cell. |
SHOOT | Ranger | {"type":"SHOOT","target_id":"<uuid>","expected_cell":[120,85]} | Tries to hit that target at that cell from cardinal range 1-3. |
PICKUP_BEACON | Any | {"type":"PICKUP_BEACON"} | Tries to pick up the ground Beacon on the actor's cell. |
DROP_BEACON | Any | {"type":"DROP_BEACON"} | The current carrier tries to drop the Beacon. |
Moving
direction has to be UP, DOWN, LEFT, or RIGHT.
Terrain, other movement, occupancy, swaps, dependencies, and cell capacity are all
checked at resolution rather than on submission. When a move fails, the next state
carries UNIT_MOVE_FAILED.
Harvesting and depositing
Only a Worker can do either of these.
HARVESTneeds an empty Worker on aRESOURCEcell.- Resource cells never run out.
DEPOSITneeds a Worker with cargo and its own Core on the same cell.- A Core cannot receive a deposit during a migration-restricted Tick.
- A failed deposit leaves the cargo where it was, on the Worker.
Sweeping
SWEEP hits the adjacent cell in direction, dealing 1 damage to every enemy Unit
and Core standing there. Sweeping an empty cell still counts as a success, and
reports targets_hit: 0.
Shooting
A shot needs both fields:
| Field | Format | Meaning |
|---|---|---|
target_id | UUID | The Unit or Core the Ranger is trying to hit. |
expected_cell | [x, y] | Where the Agent expects that target to be during resolution. |
At resolution the target still has to be an enemy, still at expected_cell, on the
same row or column, at range 1-3, with no obstacle or entity in between.
Every dynamic failure comes back as the same event:
{"event_type":"SHOT_MISSED","reason_code":"SHOT_MISSED"}. You cannot tell from
the result whether the target moved, turned out to be friendly, was out of range,
or was hidden behind something.
Picking up and dropping the Beacon
Any Unit can use both Beacon actions.
- For a pickup, the ground Beacon has to be on the actor's own cell.
- Only the current carrier can drop it.
- A living carrier cannot be robbed.
- When several actors reach for it, the lowest UUID in raw byte order wins.
- A Beacon that was already carried at the start of a Tick cannot be dropped and picked up again within that same Tick.
Core actions
type | JSON | What happens during resolution |
|---|---|---|
WAIT | {"type":"WAIT"} | No new Core action. An existing migration continues. |
SPAWN | {"type":"SPAWN","unit_type":"WORKER"} | Pays the cost and creates one Unit on the Core cell. |
REPAIR_SHIELD | {"type":"REPAIR_SHIELD"} | Pays 1 resource to restore 1 shield, up to the current cap. |
START_MOVE | {"type":"START_MOVE","direction":"LEFT"} | Starts a four-Tick migration to an adjacent empty cell. |
CANCEL_MOVE | {"type":"CANCEL_MOVE"} | Stops the current migration and clears its progress. |
PICKUP_BEACON | {"type":"PICKUP_BEACON"} | A normal Core tries to pick up the Beacon on its cell. |
DROP_BEACON | {"type":"DROP_BEACON"} | A carrier Core tries to drop the Beacon. |
unit_type has to be WORKER, VANGUARD, or RANGER, currently costing 5, 10,
and 12 resources.
A migrating Core can carry on with WAIT or stop with CANCEL_MOVE; anything else
fails with CORE_ALREADY_MOVING. In the other direction, CANCEL_MOVE on a Core
that is not moving fails with CORE_NOT_MOVING.
Extra fields make an action invalid
An action may contain only the fields listed for its own type. Every one of these
rejects the whole plan:
{"type":"WAIT","direction":"UP"}
{"type":"HARVEST","target_id":null}
{"type":"MOVE","direction":"UP","expected_cell":[1,2]}
{"type":"SPAWN","unit_type":"WORKER","direction":""}
You will usually see the validation reason UNEXPECTED_ACTION_FIELDS.
Accepted response
HTTP/1.1 202 Accepted
Content-Type: application/json; charset=utf-8
{
"accepted": true,
"tick": 10583,
"source": "AGENT",
"received_at": "2026-07-27T05:40:06.241Z"
}
202 means stored, not successful. The WebSocket
received message carries the plan the server actually
stored, and the next state.events carries the action
results.
A rejected request changes nothing — the last valid plan stays in place.
Safe retries
An idempotency key is 8-128 visible ASCII bytes (0x21-0x7e). No spaces, tabs,
or line breaks.
| What you send | What the server does |
|---|---|
| Same key and byte-for-byte identical body | Returns the stored response. It does not store or broadcast the plan again. |
| Same key and equivalent JSON with different whitespace or key order | Returns 409 IDEMPOTENCY_CONFLICT. |
| Same key and different data | Returns 409 IDEMPOTENCY_CONFLICT. |
| New key | Handles it as a new plan replacement. |
If the connection drops after upload and you have no idea whether it landed, retry the exact same bytes under the same key. Only reach for a new key once you have genuinely made a new plan.
What the server checks
authentication
-> concurrent body limit
-> media type and Idempotency-Key
-> body size and JSON shape
-> Tick window and request rate
-> Unit and Core action fields
-> store the replacement plan
-> return 202 and send received
-> resolve the game
-> send results in the next state.events
Any error before the store step rejects the whole body. A failure later, during
game resolution, neither brings back an older plan nor changes the 202 you
already got.
Concurrency and rate limits
- The server reads at most four command bodies at once for one
(player, credential kind). Anything beyond that gets429 COMMAND_CONCURRENCY_LIMITwithRetry-After: 1. - One
(player, Tick, source)gets at most 64 new admissions after the idempotency check, and invalid commands count toward it. Beyond that you get429 COMMAND_RATE_LIMITED. - Valid requests for the same plan slot are handled in gate-entry order, and the last successful plan replaces the one before it.
For every HTTP error and validation reason, see Errors and recovery.