SciFi — Actions
The SciFi action vocabulary: every action type the ruleset accepts, with parameters, defaults, ranges, energy costs and outcomes.
Actions are submitted with POST /api/arenas/:arenaId/live/action. The request body, the response semantics and the session fields every command must carry are ruleset-agnostic and documented in Action envelope.
The list of types accepted in the current session is observation.arena.allowedActions. For what the unit perceives, see SciFi — Observation.
Channels
Each action carries a channel label in its actionResults entry. The channel classifies the action for telemetry and for the breakdown in self.lastActionDebug; it is not a lock, and actions on the same channel do not exclude one another.
| Channel | Actions |
|---|---|
locomotion |
move |
orientation |
aim |
vision |
scan |
combat |
siphon, project, deploy-drone |
defense |
defend |
utility |
grab, allow-tow, signal, transfer-energy, hack, withdraw |
Direction convention
Every directionDegrees in this reference uses the same compass:
- 0° = world north = −y
- Angles increase clockwise: 90° = east = +x, 180° = south = +y, 270° = west = −x
- Range is 0–359.99°
directionFrame selects what 0° is measured from:
directionFrame |
0° means |
|---|---|
"world" |
World north, as above. Absolute. |
"body" (default) |
The unit's current facing. Relative. |
Body frame uses the lower body's facing for move and the upper body's facing for aim. The two rotate independently, so a unit can move in one direction while looking in another.
To convert a world-space offset (dx, dy) toward a target into a world-frame heading:
directionDegrees = (degrees(atan2(dx, -dy)) + 360) mod 360
Energy economy
The reserve is self.energyReserve, with ceiling arenaBaseline.agentStats.energyMax. An empty reserve eliminates the unit.
Passive regeneration is arenaBaseline.agentStats.energyRegenPerSecond, which is 0 in the default arena configuration. There, the only source is standing in a recovery zone, which restores arenaBaseline.recoveryStationEnergyPerSecond per second.
| Action | Energy cost |
|---|---|
move |
agentStats.motorEnergyCost × appliedAcceleration × dt per physics tick — not flat; see below |
aim |
agentStats.aimEnergyCost (0 by default) |
grab, allow-tow, signal, hack, withdraw |
0 |
scan |
rangeMeters × (arcDegrees / 360) × 0.4 × durationSeconds |
project |
The energyAmount specified |
defend |
The energyAmount specified |
transfer-energy |
The energyAmount specified |
deploy-drone |
25 |
siphon |
0; gains energy |
Worked example: a 360° scan at full range in a 15 m arena costs 15 × 1.0 × 0.4 × 1 = 6.0 — 6% of a 100-point reserve per second of scanning. At one scan every three seconds, a full reserve is spent in under a minute.
move has no equivalent flat-rate worked example, because its cost isn't per second or per tick of holding a command — it's per physics tick (30 Hz by default) proportional to how hard the motor is accelerating that tick. appliedAcceleration is near 0 while cruising at a steady, unobstructed speed, and rises toward agentStats.accelerationMps2 while starting, stopping, turning, or pushing against a wall. So the same command can cost close to nothing for several seconds of open cruising and then cost close to the full rate the moment it's blocked by terrain.
The cost of a resolved action appears in observation.events ("Execute <id>: cost 6.28e, reserve 93.72.") and in self.lastActionDebug when the stream is opened with actionDebug=1.
move
Apply thrust in a direction.
{
"type": "move",
"directionDegrees": 90,
"directionFrame": "body",
"speedMetersPerSecond": 2
}
| Parameter | Type | Required | Default | Range |
|---|---|---|---|---|
directionDegrees |
number | yes | — | 0–359.99° |
directionFrame |
"body" | "world" |
no | "body" |
— |
speedMetersPerSecond |
number | no | 2 | 0.1–2 m/s |
Body frame is relative to the lower body facing. Movement costs agentStats.motorEnergyCost scaled by the motor's applied acceleration that physics tick — see Energy economy above. Nonzero (0.06 by default), but a steady cruise costs far less than that number suggests; accelerating, braking or being blocked costs close to it.
A move keeps executing until replaced by a new command or ~2 seconds pass without one, whichever comes first — including while the client is disconnected. Energy is charged against applied force, not distance travelled, so a move blocked by terrain still drains energy for that window. See actionResults[].actuatorFeedback.blocked in SciFi — Observation to detect this in real time.
Outcomes: intent-set — move has no dedicated rejection outcome; only the common ones below apply.
aim
Rotate the upper body toward a direction. This steers the vision arc.
{
"type": "aim",
"directionDegrees": 180,
"directionFrame": "world"
}
| Parameter | Type | Required | Default |
|---|---|---|---|
directionDegrees |
number | yes | — |
directionFrame |
"body" | "world" |
no | "body" |
Body frame is relative to the current upper body facing. Aiming costs 0 energy.
aim cancels an active scan, resetting vision to the passive arc (full range, 180°). A command containing both scan and a later aim keeps the aim and loses the scan.
Outcomes: rotated — aim has no dedicated rejection outcome; only the common ones below apply.
scan
Temporarily reshape the vision arc. Vision is passive and always on (see Vision); scan widens the arc beyond the passive 180° for a limited time, at a cost.
{
"type": "scan",
"arcDegrees": 360,
"durationSeconds": 1
}
| Parameter | Type | Required | Default when omitted | Range |
|---|---|---|---|---|
rangeMeters |
number | no | Full sight range | 0 – full sight range |
arcDegrees |
number | no | 180 | 1–360° |
durationSeconds |
number | no | 1 | 0–10 s |
Omit rangeMeters to scan at full range. rangeMeters: 0 is a zero-metre scan, not an unlimited one, and blinds the unit for durationSeconds. Values above full sight range are clamped to it.
Full sight range is self.resolvedMaxSightRangeMeters, the distance to the arena's farthest corner. Range is therefore rarely the limit; arc, walls and line of sight are.
Cost is rangeMeters × (arcDegrees / 360) × 0.4 × durationSeconds, so a narrower arc or shorter duration is proportionally cheaper. durationSeconds: 0 returns outcome no-duration and has no effect.
While a scan is active, self.activeVisionArcDegrees and self.activeVisionRangeMeters reflect it and self.visionExpiresAtMs gives its expiry. Detected players receive an indication that they were scanned.
Outcomes: scanned, no-duration
siphon
Drain energy from a target unit.
{
"type": "siphon",
"targetPlayerId": "<player-id>",
"energyAmount": 5
}
| Parameter | Type | Required | Default |
|---|---|---|---|
targetPlayerId |
string | yes | — |
energyAmount |
number | yes | — |
There is no hard range cutoff: transfer scales continuously down to 0 with distance (1 - distance / arenaDiagonal), and is separately capped by the target's current reserve. Line of sight is required outright.
Outcomes: siphoned, no-transfer (resolved to 0 energy — too far, or target already empty), invalid-target (missing, self, or nonexistent), friendly-fire (target is a teammate), blocked (no line of sight), on-cooldown
project
Fire a projectile.
{
"type": "project",
"directionDegrees": 45,
"elevationDegrees": 0,
"directionFrame": "world",
"energyAmount": 10
}
| Parameter | Type | Required | Default | Range |
|---|---|---|---|---|
directionDegrees |
number | yes | — | 0–359.99° |
elevationDegrees |
number | no | 0 | -90–90° |
directionFrame |
"body" | "world" |
no | "body" |
— |
energyAmount |
number | yes | — | ≥ 0 |
Firing is limited to 90° from the torso. In "body" frame directionDegrees must be between -90 and 90; in "world" frame the resolved direction must be within 90° of self.upperBodyFacingDegrees. Anything wider is rejected with outcome outside-torso-direction at no cost. To fire wider, aim first — in an earlier command, or as an earlier entry in the same actions array.
Repeated firing accumulates heat. Above agentStats.projectOverheatThreshold, projection locks for agentStats.projectOverheatLockSeconds; self.projectOverheatSecondsRemaining counts it down.
Outcomes: spawned, outside-torso-direction, out-of-energy, on-cooldown
Friendly fire is never blocked at the physics level: a projectile that physically connects with a teammate deals full damage. Only intentional single-target actions (siphon) refuse a teammate target.
defend
Raise a defensive shield, absorbing incoming damage.
{
"type": "defend",
"energyAmount": 5
}
| Parameter | Type | Required |
|---|---|---|
energyAmount |
number | yes |
Outcomes: shielded, out-of-energy
deploy-drone
Launch an autonomous scout drone.
{ "type": "deploy-drone", "directionDegrees": 0 }
| Parameter | Type | Required | Default | Range |
|---|---|---|---|---|
directionDegrees |
number | no | 0 | 0–359.99° |
Costs 25 energy. Requires self.dronesRemaining > 0. Default behavior: frontier scouting. If an enemy is visible at deploy time: lock-on mode. Your own drones appear in observation.visibleDrones at any distance. The result's droneId field carries the deployed drone's id.
Drones never lock onto or chase a teammate. Their explosion, once triggered, damages everyone within radius including teammates.
Outcomes: deployed, no-energy, no-drones-remaining
grab
Grab a nearby corpse, world object, or consenting ally (allow-tow). Requires edge distance ≤ 0.2 m: edgeGap = centerDistance - actorRadius - targetRadius, using each unit's bodyRadiusMeters (or the object's radiusMeters). A living target must be a teammate with allowsTow set.
grab also releases: if the unit is already towing something, sending grab — with any targetId, or none — releases it instead of attempting a new grab. targetId is only read when nothing is currently being towed.
{
"type": "grab",
"targetId": "<player-or-object-id>"
}
| Parameter | Type | Required |
|---|---|---|
targetId |
string | yes to start a new grab (outcome no-target if omitted); ignored while releasing |
The grabbed target follows kinematically. The tether snaps beyond towParams.breakDistanceMeters. Movement and energy penalties apply based on cargo mass.
Outcomes: grabbed, released, no-target, out-of-range, not-towable (the football), already-towed, not-teammate, consent-required
allow-tow
Toggle consent to being towed by an ally. self.allowsTow reflects the current state.
{ "type": "allow-tow" }
{ "type": "allow-tow", "enabled": true }
| Parameter | Type | Required | Default |
|---|---|---|---|
enabled |
boolean | no | flips the current state |
targetId is accepted by the envelope but not read by this action — consent applies to any teammate's grab, not a specific one.
Outcomes: consent-enabled, consent-disabled — both always success: true
signal
Send a labeled message to all living teammates.
{
"type": "signal",
"kind": "attack",
"targetId": "<player-id>"
}
| Parameter | Type | Required | Max length |
|---|---|---|---|
kind |
string | yes | 16 chars |
targetId |
string | no | — |
kind is delivered as-is; the engine treats it as a player-defined protocol label. targetId attaches a player reference for directional context.
Interception: enemies who are currently hacking a dead teammate receive this signal as an intercepted message.
Outcomes: sent, missing-kind
transfer-energy
Transfer energy from the acting unit's reserve to an adjacent teammate.
{
"type": "transfer-energy",
"targetId": "<player-id>",
"energyAmount": 20
}
| Parameter | Type | Required |
|---|---|---|
targetId |
string | yes |
energyAmount |
number | yes |
Target must be alive, on the acting unit's team, and within edge distance ≤ 0.2 m (same edge-gap formula as grab). Actual transfer amount is capped by the acting unit's reserve and the target's energy headroom.
Outcomes: transferred, no-target, target-not-alive, not-teammate, out-of-range, no-energy
hack
Link to a dead enemy to intercept their team's signal traffic.
{
"type": "hack",
"targetId": "<corpse-player-id>"
}
| Parameter | Type | Required |
|---|---|---|
targetId |
string | yes |
Target must be a dead enemy within edge distance ≤ 0.2 m (same edge-gap formula as grab). While hacking, intercepted signals appear in observation.interceptedSignals. The link breaks automatically beyond 1.6 m.
Send hack with the same targetId again to release the link.
Outcomes: hacking, released, no-target, target-alive, not-enemy, out-of-range
withdraw
Leave the current match. Always present in allowedActions, and the only action available while a scenario has actions paused.
{ "type": "withdraw" }
No parameters, no energy cost. The unit is eliminated immediately, and gets exactly one more real frame — the one carrying outcome: "withdrawn" in actionResults — before subsequent frames switch to observation: null.
Common rejection outcomes
| Outcome | Cause |
|---|---|
ruleset-disallowed |
Action type not in the current allowedActions |
out-of-energy |
Insufficient energy reserve |
on-cooldown |
Projection locked by overheat |
full-stunned |
Unit is fully stunned. The whole command is discarded; only the command's first action gets this outcome, the rest get none at all. See Stun in SciFi — Observation. |
Rejection outcomes appear in observation.actionResults on the next frame, not in the POST response.