Skip to content

Latest commit

 

History

History
634 lines (512 loc) · 33.8 KB

SCRIPTING_API.md

File metadata and controls

634 lines (512 loc) · 33.8 KB

Ship Scripting API

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.

Script lifecycle

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):

  1. Create — give it a name, source, and mode.
  2. Enable — the source is loaded into the sandbox and starts running.
  3. Edit — bumps the version; if enabled, the new source hot-reloads.
  4. 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.)

Modes & entry points

Polling mode

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.

Event mode

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).

Onetime mode

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.run

run() 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).

Calling scripts from scripts (ship.scripts.run)

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 ship object

The only global injected into your script. Everything else is standard Python.

ship.engines

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", ...)

ship.navigation

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).

ship.shields

Method Returns
set_power_allocation(power) {"shields_power"} — 0–100
get_status() {"current", "max", "recharge_rate", "power_fraction"}

ship.weapons

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", ...)

ship.sensors

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'.

ship.power

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).

ship.display

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)

ship.scripts

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 names

ship.storage

Persistent 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.comms

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

ship.exploration

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

ship.trade

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.

ship.missions

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"])

ship.mining

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 loop

ship.shipyard

Ship 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)

ship.fabrication

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")

Renaming your ship

game_control.ship.rename(name) → {"name"} — rename your ship (1–40 chars). The new name shows in scans, comms, and other players' HUDs.

Events

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"])

CPU budget

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}}

Worked example: damage-responsive autopilot

# 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)

Limitations & gotchas

  • No imports of arbitrary packages — the sandbox exposes the standard language plus the ship object. 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.