Ship scripts are Python, executed server-side in a Pyodide (WASM) sandbox
— one isolated namespace per script. They call the same action API as manual
input and the MCP agent (spec §5), tagged with actor script:<name> in the
action log.
Manage scripts from the client's script editor (backtick key), via the
WebSocket action API, via your MCP agent, or from another script via
ship.scripts (below):
- Create — give it a name, source, and mode.
- Enable — the source is loaded into the sandbox and starts running.
- Edit — bumps the
version; if enabled, the new source hot-reloads. - Disable / delete — unloads it from the sandbox.
Scripts persist to your user profile (server/data/<username>.json) —
they survive page refreshes and server restarts, along with your ship's
position, hull, and settings. (They come back disabled after a restart —
re-enable to run them again.)
Define loop() — it runs once per logic tick (1 Hz by default, see
docs/CONFIG.md):
async def loop():
status = await ship.engines.get_status()
if status["speed"] > 20:
await ship.engines.set_thrust(0.0)loop() may be sync or async; both work.
Define on_<event>(payload) handlers and subscribe to events. Handlers run
at the next logic tick after the event is queued (spec §5.3) — never
mid-tick:
async def on_damage_taken(payload):
await ship.display.log(f"Hit for {payload['amount']:.0f}!")
await ship.shields.set_power_allocation(80)Subscribe via the script editor's event picker or
game_control.script.schedule(events=["damage_taken", ...]).
A single script may mix both styles (define loop() and handlers).
Define run() — it runs once when the script is enabled, then the script
automatically disables itself (and stays disabled after a restart). Ideal for
one-shot sequences like "scan and summarize":
async def run():
result = await ship.sensors.scan()
names = [c["name"] for c in result["contacts"]]
await ship.display.log(f"contacts: {', '.join(names) or 'none'}")
return names # returned to callers of ship.scripts.runrun() may be sync or async; both work. If run() isn't defined, loop()
is called instead. A failing onetime script disables itself too — it won't
retry every tick. Its return value is delivered to anyone who invoked it via
ship.scripts.run(name) (see ship.scripts below).
Any script can execute another script's run(*args) immediately and get
its return value back — a simple way to share code between scripts:
r = await ship.scripts.run("scan_and_summarize")
# r == {"result": [...names...], "cpuMs": 3.2}The target script doesn't need to be enabled — it's loaded into the sandbox
on demand. Arguments are positional: await ship.scripts.run("plot", x, y)
calls run(x, y) in the target script.
The only global injected into your script. Everything else is standard Python.
| Method | Returns | Notes |
|---|---|---|
set_thrust(forward) |
{"thrust": f} |
−1..1; state-setting — persists until changed (spec §5.2) |
get_status() |
{"thrust_request", "power_fraction", "velocity", "speed"} |
|
configure(power) |
{"engines_power"} |
0–100; alias for power.allocate("engines", ...) |
Angles are compass bearings in degrees for all user-facing calls — 0=N,
90=E, 180=S, 270=W. You never need radians: get_position returns
heading_bearing (where the nose points), course_bearing (where you're
moving), and retrograde_bearing (heading that brakes you), all in degrees.
The raw orientation field is internal radians with a +90° offset from
bearing — never convert it yourself; use the bearings.
| Method | Returns | Notes |
|---|---|---|
bearing_to(x, y) |
{"bearing", "distance"} |
Compass bearing (degrees) and distance to a point |
face(x=None, y=None, bearing=None) |
{"facing_bearing"} |
Turn toward a coordinate or a compass bearing |
fly_to(x, y, stop_distance=50) |
{"started", "distance"} |
Autopilot — faces the target, thrusts, brakes, stops within stop_distance. Progress in the autopilot readout; disables itself on arrival |
cancel_autopilot() |
{"cancelled"} |
Stop the autopilot: cuts thrust, disables its script. Safe to call when not running |
set_heading(absolute=None, relative=None) |
{"heading", "turn"} |
Low-level: radians / turn rate −1..1 |
get_position() |
{"position", "velocity", "orientation", "heading_bearing", "course_bearing", "retrograde_bearing", "speed", "in_warp"} |
Authoritative server state. Bearings are degrees (0=N); retrograde_bearing is the heading that brakes you. Don't convert orientation yourself — it's radians with a +90° offset from bearing |
scan_cartography(x=None, y=None) |
{"systems": [...]} |
Star systems near a coordinate (defaults to current position); search radius from config.json → galaxy.cartography_radius_sectors. Each system includes star_id and a bodies list (id, kind, name, position) with player-assigned names overlaid |
waypoints() |
{"beacons": [...], "station", "jump"} |
Your markers, galaxy-wide: your nav beacons (id, label, position, bearing (compass degrees), distance, expires_at), your outpost, and the plotted jump destination. Own objects only — others' beacons/stations are sensor contacts, not waypoints. Compose with fly_to: see below |
plot_jump(x, y) |
JumpRoute dict | Computes mass-shadow-aware waypoints and stores the route (spec §8); the ship also turns toward the destination (plotting implies intent to travel that way) |
compute_route(x, y) |
JumpRoute dict | Like plot_jump but does not store the route — preview only |
engage_jump() |
{"engaged", "waypoints"} |
Requires a valid plotted route |
abort_jump() |
{"aborted"} |
Typical navigation — no math required:
# fly to the moon the cartography scan found
await ship.navigation.fly_to(25519.2, 26835.7)
# or manually:
b = await ship.navigation.bearing_to(25519.2, 26835.7) # {"bearing": 42.3, "distance": 947.1}
await ship.navigation.face(bearing=b["bearing"])
await ship.engines.set_thrust(0.8)JumpRoute shape: {"origin", "destination", "waypoints": [...], "estimated_cost", "valid", "reason"}.
Check valid before engage_jump() — an invalid route (e.g. too long) will be rejected.
Beacon → navigation composition pattern — the navigation API is coordinate-based, and beacons are how a place gets a name you can find again (update 5). The intended flow is two composed calls, no new action surface:
# fly back to the beacon you dropped at a rich deep belt
w = await ship.navigation.waypoints()
b = next((b for b in w["beacons"] if b["label"] == "DB-417 home"), None)
if b:
await ship.navigation.fly_to(b["position"]["x"], b["position"]["y"])waypoints() lists your own beacons from anywhere in the galaxy
(listing your own placements isn't omniscience — you put them there) plus
your outpost and the plotted jump. Everyone else's beacons are sensor
contacts only (sensors.scan → kind: "beacon" with owner + label,
within sensor range).
| Method | Returns |
|---|---|
set_power_allocation(power) |
{"shields_power"} — 0–100 |
get_status() |
{"current", "max", "recharge_rate", "power_fraction"} |
| Method | Returns | Notes |
|---|---|---|
fire(target=None, direction=None) |
{"fired", "projectile"} or {"fired": False, "reason": "cooldown"} |
target = contact id; direction = radians |
set_targeting(target=None) |
{"target"} |
Set/clear the target lock (contact id) |
get_status() |
{"cooldown_remaining", "target", "power_fraction"} |
|
configure(power) |
{"weapons_power"} |
0–100; alias for power.allocate("weapons", ...) |
| Method | Returns | Notes |
|---|---|---|
scan(scan_type="passive") |
{"scan_id", "contacts": [...]} |
Contacts are last-known data as of the scan (spec §4) |
get_contacts() |
{"contacts": [...]} |
From the most recent scan |
get_scan_result() |
{"scan_id", "ship_id", "scan_type", "contacts", "timestamp"} |
Full result of the most recent scan |
Contact shape: {"contact_id", "kind", "name", "position", "velocity", "scanned_at"}.
Player-built objects (update 5) also carry owner: kind: "station"
(player outposts — world structures) and kind: "beacon" (nav beacons,
detectable only within sensor range). Use owner to tell your objects
from other players'.
| Method | Returns | Notes |
|---|---|---|
get_status() |
{"reactor_output", "allocated", "unallocated", "allocation"} |
|
allocate(system, amount) |
{"allocation": {...}} |
system ∈ engines, shields, weapons, sensors, jump_drive; 0–100 |
Under-allocating a system degrades it proportionally — slower shield recharge, reduced thrust/turn, longer weapon cooldown (spec §11).
| Method | Returns | Notes |
|---|---|---|
set_readout(label, value) |
{"ok": True} |
Custom label/value shown on your HUD |
log(message) |
{"ok": True} |
Line in the ship's display log (bottom-left) |
Script self-management — lets scripts coordinate and clean up after themselves:
| Method | Returns | Notes |
|---|---|---|
list() |
{"scripts": [...]} |
All scripts with name/mode/enabled state |
get(name) |
ScriptSlot dict | Source, version, mode, subscriptions |
enable(name) / disable(name) |
{"name", "enabled"} |
Start/stop a script (including your own) |
create(name, source, mode) |
ScriptSlot dict | Create a new script (mode: polling, event, onetime) |
edit(name, source, version) |
ScriptSlot dict | Version-checked edit |
delete(name) |
{"deleted"} |
Remove a script |
schedule(name, mode, events) |
ScriptSlot dict | Set mode / event subscriptions |
run(name, *args) |
{"result", "cpuMs"} |
Call a script's run(*args) (or loop()) once now and return its value |
Examples — one per script mode (created with create(name, source, mode),
started with enable(name); see Modes & entry points):
Polling — standing automation; loop() runs every logic tick (1 Hz):
# mode: polling
async def loop():
# cancel any velocity above 5 units/s by thrusting against it
pos = await ship.navigation.get_position()
if pos["speed"] > 5:
await ship.navigation.face(bearing=pos["retrograde_bearing"])
await ship.engines.set_thrust(0.5)
else:
await ship.engines.set_thrust(0.0)Event — on_<event>(payload) handlers fire on subscribed events:
# mode: event, events: ["damage_taken"]
async def on_damage_taken(payload):
await ship.display.log(f"hit for {payload['amount']:.0f}")
await ship.power.allocate("shields", 60)Onetime — run() executes once when the script is enabled, then the
script auto-disables itself (no manual cleanup needed):
# mode: onetime
async def run():
result = await ship.sensors.scan()
names = [c["name"] for c in result["contacts"]]
await ship.display.log(f"contacts: {', '.join(names) or 'none'}")
return namesPersistent key/value storage — survives disconnects and server restarts.
Ship-scoped by default (shared by all your scripts, the REPL, and your MCP
agent — good for settings); pass script= for a script's private storage.
| Method | Returns | Notes |
|---|---|---|
get(key=None, script=None) |
{"key", "value"} or {"variables": {...}} |
Omit key to dump the whole dict; missing key raises |
set(key, value, script=None) |
{"key", "value"} |
Any JSON-serializable value; written through to the profile immediately |
Example — a settings script other scripts read:
# script "configure" (run once via ship.scripts.run or onetime mode)
async def run():
await ship.storage.set("patrol_radius", 400)
await ship.storage.set("home", {"x": 100, "y": -50})
# any other script / a polling loop:
r = await ship.storage.get("patrol_radius")
radius = r["value"]Ship-to-ship messaging. Range is limited (config comms.max_range, default
50,000 units — read the exact value from the send result).
| Method | Returns | Notes |
|---|---|---|
send(message, target=None) |
{"delivered", "range"} |
target=None broadcasts to all active ships in range; target is a ship id (ship_alice) or a contact id from your last scan. Messages longer than comms.max_message_length are truncated. Recipients get a message_received event and a [comms] display-log line; the sender gets an echo line — broadcasts under their own name, directed sends as <sender> → <recipient> (recipients see <sender> → you) |
get_log(limit=20) |
{"messages": [...]} |
Session-only history (newest last); entries: direction (sent/received), from, from_name, to, message, broadcast, sent_at |
Celestial body naming. Names are world-global and persistent — the first player to name a body claims it for everyone (first-come-first-served).
| Method | Returns | Notes |
|---|---|---|
name_body(contact_id, name) |
{"contact_id", "name", "previous_name"} |
Body must be a contact from your last scan (kinds star/planet/moon/asteroid_field) and within exploration.naming_range (default 1,000) of your current position. Name 1–40 chars |
get_names(contact_ids=None) |
{"names": {id: {"name", "named_by", "named_at"}}} |
All player-assigned names, or filtered to the given ids |
Buy and sell commodities at trade ports (any planet in a trade system) —
you must be within economy.trade_range (default 500 units) of the planet.
Prices are deterministic per system and per day (identical for all players);
buying and selling at the same port loses the ~10% spread, so profit comes
from inter-system price differences. Some commodities aren't traded at
every port. Credits and cargo persist with your ship.
| Method | Returns | Notes |
|---|---|---|
list() |
market dict | {"planet": {"id", "name"}, "commodities": [{"id", "buy_price", "sell_price", "your_cargo", "in_demand"}], "credits", "cargo_used", "cargo_capacity"} — requires a trade port in range |
buy(commodity, quantity) |
{"commodity", "quantity", "unit_price", "total", "credits", "cargo_used"} |
Rejects unknown commodities, commodities not traded at this port, insufficient credits, cargo hold full |
sell(commodity, quantity) |
same shape | Requires holding at least quantity |
dump(commodity, quantity) |
{"commodity", "quantity", "cargo", "cargo_used", "cargo_capacity"} |
Jettison — deletes units from the hold anywhere, no refund (free space for more valuable cargo). Mission cargo can't be dumped; abandon the mission instead |
get_account() |
{"credits", "cargo", "cargo_used", "cargo_capacity"} |
Works anywhere — check balance/holdings while flying |
Commodities: ore, fuel, food, electronics, medicine, alloys, rare_earth.
Every item occupies 1 unit of shared cargo capacity. rare_earth is mined
from rare-composition asteroids (see ship.mining) — high price, high
volatility, and omitted from some markets like any commodity.
Server-generated delivery jobs. View/accept offers at a trade port (same
proximity rule as trading); boards are deterministic per planet and refresh
hourly. Accepting adds mission cargo (mission:<offer_id> — it occupies
shared hold space and can't be sold; abandon to forfeit). Completion is
automatic: fly within missions.completion_range (default 500) of the
destination planet with the cargo aboard — credits are paid on arrival.
Accepted missions persist and have a time limit (expiry forfeits the cargo).
| Method | Returns | Notes |
|---|---|---|
list_offers() |
{"planet", "offers": [...]} |
Board at the nearest planet. Each offer: offer_id, pickup, destination (name/position), commodity, quantity, reward_credits, expires_at, plus accepted / cargo_full flags |
accept(offer_id) |
mission dict | Adds mission cargo + creates the active mission. Rejects: offer rotated/expired, mission log full, duplicate, cargo hold full |
get_active() |
{"missions": [...], "max_active"} |
Works anywhere. Each mission: status fields incl. destination (name/position), distance_to_destination, bearing_to_destination (compass degrees), completed, expired |
abandon(offer_id) |
{"abandoned"} |
Removes the mission + its cargo, no penalty |
Example — a delivery autopilot script:
# mode: polling
async def loop():
r = await ship.missions.get_active()
m = next((x for x in r["missions"] if not x["completed"]), None)
if m is None:
await ship.scripts.disable("courier")
return
await ship.navigation.fly_to(m["destination"]["position"]["x"],
m["destination"]["position"]["y"])The mining beam (update 4): a continuous, state-setting beam that grinds
an asteroid down over time and converts it into commodity cargo — rock→
ore, ice→fuel, metal→alloys, rare→rare_earth. Power is shared
with the cannon on the weapons bus (configure(power)), so cutting rock
competes with fighting. The beam is rejected during warp and auto-stops when
the hold fills.
There is no auto-targeting. You must survey a field and set_target a
specific rock before the beam can be engaged — this keeps you from
accidentally scooping a full hold of low-value rock. The beam is
target-locked: whenever the target is lost (cleared, out of range, gone,
depleted) or the hold fills, the beam disarms — re-engaging is a
deliberate act. Yield is slow by design, scaling with the material's value:
bulk ore ≈ a unit a second or two, rare_earth takes several seconds per
unit.
Asteroid depletion is session state: the universe resets per session (spec §2) — every belt is fully restored after a server restart, and survey history (which fields your client renders in color) resets with it. Don't script around belt memory across restarts.
| Method | Returns | Notes |
|---|---|---|
survey(field_contact_id) |
{"field", "asteroids": [...]} |
Break an asteroid_field contact (from your last scan) down into minable rocks, best rock first (units_remaining × richness). Requires the field within ~1.5 sensor ranges. Each rock: id, position, radius, composition, richness, units_remaining (exact), units_max. Deep-space belts are asteroid_field contacts with deep: true (richer, rarer). Surveying a field also reveals its rocks' composition to your client's map |
set_beam(enabled) |
{"beam", "target"} |
Engage/disengage — state-setting. Requires an explicit, in-range, non-depleted target and fails fast with the geometry otherwise (target out of beam range: 2140 units away (beam range 300) — fly closer); rejected during warp; disarms itself when the target is lost or the hold fills |
set_target(asteroid_id=None) |
{"target"} |
Aim at a specific rock (id from survey); None just clears the target (the beam disarms next tick if it was running) |
get_status() |
{"beam_active", "target", "range", "yield_per_second", "power_fraction", "cargo_used", "cargo_capacity"} |
target reports geometry at any distance: {id, composition, position, units_remaining, units_max, distance, in_range} — so scripts/agents can navigate to an out-of-range target (distance > range → fly closer) |
configure(power) |
{"weapons_power"} |
0–100; alias for power.allocate("weapons", ...) (shared bus) |
Example — the flagship auto-miner (polling mode; survey → target → beam):
# mode: polling
target_rock = None
async def loop():
global target_rock
st = await ship.mining.get_status()
if st["cargo_used"] >= st["cargo_capacity"] - 2:
await ship.mining.set_beam(False)
# sell at nearest port, then return — survey again after
r = await ship.trade.sell("ore", 999)
await ship.display.log(f"sold {r['quantity']} ore for {r['total']} cr")
return
if not st["beam_active"]:
rocks = None
for c in (await ship.sensors.scan(scan_type="passive"))["contacts"]:
if c["kind"] == "asteroid_field":
rocks = await ship.mining.survey(c["contact_id"])
break
if rocks and rocks["asteroids"]:
target_rock = rocks["asteroids"][0]
await ship.navigation.fly_to(target_rock["position"]["x"],
target_rock["position"]["y"])
await ship.mining.set_target(target_rock["id"])
await ship.mining.set_beam(True)
# the beam disarms on out_of_range/depletion — re-target next loopShip components & upgrades (update 5). Nine components — engines,
shields, weapons, sensors, reactor, hull, cargo, mining,
jump_drive — each with sequential levels (Mk 0 → Mk 1 → Mk 2 → Mk 3, no
skipping; some cap at Mk 1–2). Installing consumes credits + commodity
materials (alloys/electronics/ore/rare_earth — every tier-3 install gates
on rare_earth, the deep-belt hook) and recomputes your stats
immediately; the upgrade persists with your profile.
Shipyards are derived capabilities: ~35% of market planets have one (same for all players, stable per system), and your outpost at L3 is a shipyard too — the belt outpost becomes the upgrade stop.
| Method | Returns | Notes |
|---|---|---|
list() |
{"components": [...], "shipyard_in_range", "shipyard_via", "credits", "cargo"} |
Works anywhere — current levels, per-component next-level costs (credits + materials), and the shipyard capability line (via: "planet" with the planet name, or "outpost"). The planning input for upgrade runs |
install(component) |
{"component", "level", "credits", "cargo", "stats", "installed_at"} |
Install the next level at a shipyard. Rejects: unknown component, max level, no shipyard in range, missing credits/materials. Fires component_installed with the fresh stats snapshot |
upgrade_station() |
{"station": {...}, "credits", "cargo"} |
Upgrade your outpost L1→L2 (refinery) →L3 (shipyard); materials scale. Requires standing within service range of your outpost. Fires station_upgraded |
repair(amount=None) |
{"hull", "repaired", "cost", "credits", "via"} |
Repair hull at any trade port or your outpost (L1+ services). Default repairs all missing hull; pass amount for a partial repair. Costs fabrication.repair_cost_credits_per_hull (default 1.5) per point; clamps at max_hull |
get_status() |
{"components": {...}, "stats": {...}, "cargo_capacity", "hull", "shields"} |
Levels + full derived stats — anywhere. Scripts use this to adapt behavior to their own outfitting |
Component effects (per Mk, compounding): engines +15% thrust/turn ·
shields +25% max shield, +15% recharge · weapons +20% damage, −10%
cooldown · sensors +30% range · reactor +15% output · hull +25% max
hull, +10% mass · cargo +10 hold units (see trade.get_account) ·
mining +40% yield, +25% range · jump_drive +10% warp speed.
Example — re-plan after a refit:
# mode: event, events: ["component_installed"]
async def on_component_installed(payload):
await ship.display.log(f"Mk{payload['level']} {payload['component']} installed")
st = await ship.shipyard.get_status()
out = st["stats"]["reactor_output"]
# bigger reactor? spread the extra across the hot systems
share = out / 5.0
for system in ("engines", "shields", "weapons", "sensors", "jump_drive"):
await ship.power.allocate(system, share)Crafting (update 5). Recipes turn commodities into consumables (repair
kits, shield cells), deployables (nav beacons, outpost kits), and
refined goods (ore → alloys). There is no separate material inventory:
inputs are ordinary commodities from your hold, and craft outputs that
aren't commodities ride the same hold as kit:<id> entries (they occupy
capacity, can't be sold or dumped — use or deploy them).
Recipes are capability-gated by position: repair_kit/shield_cell/
nav_beacon/outpost_kit craft anywhere; refine_alloys needs a trade
port (ratio 0.5) or your outpost at L2+ (ratio 0.67 — the outpost's reason
to exist). list_recipes filters with the same resolver craft uses, so
the list never lies.
| Method | Returns | Notes |
|---|---|---|
list_recipes() |
{"recipes": [...], "cargo_used", "cargo_capacity", "credits"} |
Each recipe: id, name, inputs, output, requires, available_here, craftable, missing (exact short quantities), via (where the capability came from) |
craft(recipe_id, quantity=1) |
{"recipe_id", "quantity", "output", "via", "cargo", ...} |
Consumes inputs atomically — a failed craft consumes nothing; outputs beyond hold capacity reject up-front |
use(item_id) |
{"item_id", "effect", "hull", "shields", "cargo"} |
Consume a kit anywhere, even mid-flight: kit:repair_kit +40 hull, kit:shield_cell +50 shields (both clamped at max). Deployables reject — use deploy() |
deploy(item_id, name=None) |
deploy result | Drop a deployable at the ship's position (rejected in warp): kit:nav_beacon → TTL'd nav beacon (label defaults to <owner>'s beacon; deploying past the per-owner cap evicts your oldest, FIFO); kit:outpost_kit → your outpost, one per owner, needs 1500+ units of clearance from celestial bodies/stations, name it inline. Fires station_deployed for outposts |
V1 recipes: repair_kit (6 alloys + 2 electronics), shield_cell (4
alloys + 1 rare_earth), refine_alloys (6 ore → 3–4 alloys), nav_beacon
(3 alloys + 1 electronics), outpost_kit (60 alloys + 40 electronics +
20 rare_earth).
Example — craft a repair kit the moment you have the materials:
# mode: event, events: ["cargo_changed"]
async def on_cargo_changed(payload):
r = await ship.fabrication.list_recipes()
rk = next((x for x in r["recipes"] if x["id"] == "repair_kit"), None)
if rk and rk["craftable"]:
await ship.fabrication.craft("repair_kit")
await ship.display.log("repair kit stocked")game_control.ship.rename(name) → {"name"} — rename your ship (1–40
chars). The new name shows in scans, comms, and other players' HUDs.
Events are edge-triggered — they fire when the thing happens, not every
tick (so thrust_changed fires once per change, not continuously while
thrusting). Handlers run at the next logic tick after the event is queued
(spec §5.3) — never mid-tick. Subscribe via the script editor's event picker
or game_control.script.schedule(events=[...]).
| Event | Payload | Fired when |
|---|---|---||damage_taken|{"amount", "absorbed_by_shield", "attacker"}| A projectile hits your ship | |shields_down|{"hull", "attacker"}| Your shield pool first reaches 0 (once per depletion — recharges don't re-arm it until hit again from full) | |ship_destroyed|{"attacker"}| Hull reaches 0 (your ship leaves the sim) | |thrust_changed|{"thrust", "previous"}| Any actor (pilot, script, agent) issues a newset_thrustvalue | |heading_changed|{"heading", "previous"} | Any actor sets a new absolute heading (face, set_heading(absolute=...)) | | power_distribution_changed|{"system", "amount", "previous", "allocation"}| Any actor re-allocates reactor power | |jump_started|{"destination", "waypoints"}| Warp traversal begins | |jump_completed|{"destination"}| Warp traversal finishes (velocity is zeroed on arrival) | |jump_aborted|{"destination"}| An in-progress jump is aborted | |message_received|{"from", "from_name", "message", "broadcast", "sent_at"}| Another ship sends you a comms message (directed or broadcast) while in range | |cargo_changed|{"commodity", "quantity", "direction", "cargo", "credits"}| Any actor buys or sells at a trade port | |mission_completed|{"offer_id", "commodity", "quantity", "reward_credits", "destination"}| Ship reaches the destination planet with the mission cargo | |mission_expired|{"offer_id", "commodity", "quantity"}| An accepted mission's time limit lapses (cargo forfeited) | |mining_yield|{"asteroid_id", "field_id", "commodity", "units", "cargo_used", "cargo_capacity"}| Each integer unit-batch lands in the hold | |asteroid_depleted|{"asteroid_id", "field_id", "commodity", "units_mined_total"}| A rock's budget hits zero (it vanishes; the beam disarms) | |mining_interrupted|{"reason", "asteroid_id"}| Beam loses its rock or the hold fills and **disarms** —reason∈no_target/out_of_range/target_gone/cargo_full| |component_installed|{"component", "level", "stats"} | Shipyard install succeeds (statsis the fresh derived snapshot — re-plan power/behavior from it) | |item_crafted|{"recipe_id", "quantity", "output"} | A recipe completes (output: {id, quantity}— akit:entry or refined commodity) | |kit_used|{"item_id", "effect"} | Repair kit / shield cell consumed (effectreports what was actually applied, after clamping) | |station_deployed|{"station_id", "name", "position"}| Outpost kit deployed (beacons don't fire events — they havebeacon_ttl_sand a display-log line) | |station_upgraded|{"station_id", "level"}` | Your outpost reaches L2 (refinery) / L3 (shipyard) |
Note: heading_changed fires on the request, not on reaching the heading —
the ship then turns toward it over subsequent physics ticks. Poll
get_position() in loop() if you need arrival detection.
Example — react to damage by redistributing power:
async def on_shields_down(payload):
# shields gone: dump everything into shields + engines and run
await ship.power.allocate("shields", 60)
await ship.power.allocate("engines", 30)
await ship.display.log(f"shields down! hull {payload['hull']:.0f}")Example — a comms responder:
async def on_message_received(payload):
await ship.display.log(f"{payload['from_name']}: {payload['message']}")
if "help" in payload["message"].lower():
await ship.comms.send("On my way!", target=payload["from"])Every ship shares one CPU budget across all its scripts and its MCP agent
(spec §6). Defaults: 50 ms base per logic tick, unused budget banks up to
+50 ms, hard cap 200 ms/tick (docs/CONFIG.md → cpu_budget). The host
measures actual execution time per loop()/handler call; scripts that
exceed the budget simply get less time next tick — design loops that do less
work per tick.
Budget enforcement: the sandbox host enforces the per-call budget. A
loop()/handler that runs past its allotted budgetMs is interrupted
(KeyboardInterrupt) and reported as a script error:
budget exceeded: <N> ms. A script that repeatedly overruns will starve its
sibling scripts (they get no time that tick) — check ship.cpu_status() to
see which script is the culprit. Enforcement can be disabled with the
sandbox.enforce_budget config key (docs/CONFIG.md).
Check your budget state from a script:
status = await ship.cpu_status()
# {"budget_ms": 100.0, "banked_ms": 50.0, "cap_ms": 200.0,
# "base_ms": 50.0, "last_used_ms": 12.3, "enforced": true,
# "overruns": {"my_heavy_script": 3}}# mode: polling, events: ["damage_taken"]
fleeing = False
async def loop():
global fleeing
pos = await ship.navigation.get_position()
if fleeing:
# keep running until shields recover
s = await ship.shields.get_status()
if s["current"] >= s["max"] * 0.9:
fleeing = False
await ship.engines.set_thrust(0.0)
await ship.display.set_readout("status", "nominal")
async def on_damage_taken(payload):
global fleeing
fleeing = True
await ship.display.log(f"damage {payload['amount']:.0f} — fleeing")
await ship.display.set_readout("status", "FLEEING")
await ship.power.allocate("engines", 60)
await ship.power.allocate("shields", 30)
await ship.engines.set_thrust(1.0)- No imports of arbitrary packages — the sandbox exposes the standard
language plus the
shipobject. Don't rely on filesystem/network access. - 1 Hz cadence —
loop()is not a game loop; use state-setting calls (set_thrust,set_heading) rather than trying to steer every tick. - Contacts are stale — they reflect the scan that produced them, not live positions. Re-scan to refresh.
- Errors don't crash the ship — a failing script is skipped that tick and the error appears in the script panel's error feed (and a ⚠ warning on the HUD when the panel is closed).
- Your ship persists — position, hull, shields, power allocation, and script sources are saved on disconnect (and every 10s), and restored when you return. Transient state (warp, weapon cooldown) resets. Scripts are restored disabled: the sandbox is a per-process context, so a script that was running before a restart must be re-enabled (which reloads it into the sandbox) before it runs again.
- Components & outposts persist; the universe doesn't — installed components (and your outpost) are player state, restored with your ship. The outpost enters the session universe when you connect and stays for the session even after you disconnect (like any session world object) — "someone's outpost" ≠ "someone's ship". Beacons are session-scoped with a TTL and don't survive a server restart.