Examples/Starship

Starship

Created from a prompt

Re-run on 2026-10-16 with OpenCode (DeepSeek V4 Pro via OpenRouter) against the updated starter (scoped picture bundle, answer scrub, moveButton, key_input clocks, check_env.py). No hardcoded demo code. Cursor did not edit workspace/.

2026-10-16 · task L5i_U7CEdI2w · opencode · openrouter/deepseek/deepseek-v4-pro-0813

The prompt

Build a Starship landing you can watch in 3D. There are two vehicles. The booster, after separation, flips and burns back toward the launch site, burns again as it hits the thick air, then either settles into the tower's mechanical arms or splashes down. The ship, still in orbit, burns to fall out of orbit, hits the air belly-first behind its heat tiles, flips upright from about a kilometer up, then catches on the tower or splashes.

The tower, the arms, the pad, the water, and the ship are solid. Crashing into the tower should break things. Landing too fast should break the ship into pieces that still fall and hit the ground. A gentle upright touch should not. Wind should push the vehicle, and the push should depend on which way the wind is blowing, without a slow fluid simulation.

Show the Earth, the air getting thick, the ship, the flames, the tower, and the pieces if it breaks. There are too many controls for a keyboard. An Xbox controller should fly it: sticks for aiming and flaps, triggers for throttle, bumpers for how many engines, and buttons for catch versus splash.

Live environment

Running the prompt-created workspace on this machine — not a mock. Save an episode, then open Replay below to scrub the timeline.

opening · 0 ms

Generated workspace

Exact files the agent wrote — not rewritten by hand.

import math

from helpers import base_observation, key_input, rng_for

GAME_CLASS = "Starship"

G = 9.81
RHO0 = 1.225
H_SCALE = 8500.0
RE = 6371000.0

TOWER_H = 145.0
TOWER_R = 8.0
CATCH_Y = 118.0
CATCH_X = 18.0
CATCH_Z = 0.0
SPLASH_X = 430.0
WATER_X = 300.0
SAFE_VDOWN = 4.0
SAFE_H = 2.5

W = 960
H = 560
FOCAL = 520.0
CX = W / 2.0
CY = H / 2.0
NEAR = 3.0

SUB = 0.05

LAUNCH_T = 78.0
X_SEP = 16000.0
Y_SEP = 65000.0
RETURN_T = 92.0
ORBIT_T = 64.0
ENTRY_Y = 30000.0
ENTRY_X = 12000.0
REENTRY_T = 40.0


def clamp(x, a, b):
    return a if x < a else (b if x > b else x)


def vadd(a, b):
    return (a[0] + b[0], a[1] + b[1], a[2] + b[2])


def vsub(a, b):
    return (a[0] - b[0], a[1] - b[1], a[2] - b[2])


def vmul(a, s):
    return (a[0] * s, a[1] * s, a[2] * s)


def vdot(a, b):
    return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]


def vcross(a, b):
    return (
        a[1] * b[2] - a[2] * b[1],
        a[2] * b[0] - a[0] * b[2],
        a[0] * b[1] - a[1] * b[0],
    )


def vlen(a):
    return math.sqrt(vdot(a, a))


def vnorm(a):
    l = vlen(a)
    if l < 1e-9:
        return (0.0, 0.0, 1.0)
    return (a[0] / l, a[1] / l, a[2] / l)


def density(y):
    return RHO0 * math.exp(-max(0.0, y) / H_SCALE)


def smoothstep(t):
    t = clamp(t, 0.0, 1.0)
    return t * t * (3.0 - 2.0 * t)


class Vehicle:
    def __init__(self, kind, name, mass, f_engine, max_engines, length, color):
        self.kind = kind
        self.name = name
        self.mass = mass
        self.f_engine = f_engine
        self.max_engines = max_engines
        self.length = length
        self.color = color
        self.pos = (0.0, 0.0, 0.0)
        self.vel = (0.0, 0.0, 0.0)
        self.pitch = 0.0
        self.yaw = 0.0
        self.throttle = 0.0
        self.engines = max_engines
        self.flap = 0.0
        self.target = "catch"
        self.resolved = False
        self.caught = False
        self.splashed = False
        self.broken = False
        self.outcome = ""
        self.phase = "idle"
        self.burn = 0.0
        self.man = {}
        self.kb = {"pitch": 0.0, "yaw": 0.0, "throttle": 0.0}
        self.g_pitch = 0.0
        self.g_yaw = 0.0
        self.g_throttle = 0.0
        self.g_flap = 0.0
        self.pt = 0.0
        self.heat = 0.0

    def nose(self):
        p = math.radians(self.pitch)
        y = math.radians(self.yaw)
        return (
            math.sin(p),
            math.cos(p) * math.cos(y),
            math.cos(p) * math.sin(y),
        )

    def belly(self):
        p = math.radians(self.pitch)
        y = math.radians(self.yaw)
        return (
            math.cos(p),
            -math.sin(p) * math.cos(y),
            -math.sin(p) * math.sin(y),
        )

    def max_accel(self):
        return self.engines * self.f_engine / self.mass

    def cdrag(self):
        if self.kind == "ship":
            low, high = 0.00020, 0.00120
        else:
            low, high = 0.00028, 0.00090
        b = self.belly()
        n = self.nose()
        nrel = vnorm(self.vel)
        align = clamp(vdot(b, vmul(nrel, -1.0)), 0.0, 1.0)
        cd = low + (high - low) * align
        cd += abs(self.flap) * 0.00025
        return cd


class Starship:
    def __init__(self):
        self.t = 0.0
        self.stage = "launch"
        self.stage_t = 0.0
        self.warp = 0.12
        self.rng = rng_for("")
        self.wind_base = 8.0
        self.wind_dir = 40.0
        self.wind_z = 0.0
        self.booster = Vehicle("booster", "Booster", 200000.0, 1.0e6, 3, 71.0, "#d6d3d1")
        self.ship = Vehicle("ship", "Ship", 120000.0, 0.75e6, 3, 50.0, "#b8bdc2")
        self.pieces = []
        self.tower_damaged = False
        self.tower_lean = 0.0
        self.reward = 0.0
        self.outcomes = []
        self.last = None
        self.stack = (0.0, 0.0, 0.0)
        self.stack_vel = (0.0, 0.0, 0.0)
        self.splash_z = 0.0

    def reset(self, task_id=""):
        rng = rng_for(task_id)
        self.__init__()
        self.rng = rng
        self.wind_base = rng.uniform(5.0, 14.0)
        self.wind_dir = rng.uniform(0.0, 360.0)
        wd = math.radians(self.wind_dir)
        self.wind_z = 0.0
        self.splash_z = rng.uniform(-120.0, 120.0)
        self.booster.target = "catch"
        self.ship.target = "catch"
        return self._observe(None)

    def _wind_vec(self, y):
        wd = math.radians(self.wind_dir)
        mag = self.wind_base * (0.35 + 0.65 * clamp(y / 14000.0, 0.0, 1.0))
        return (mag * math.cos(wd), 0.0, mag * math.sin(wd))

    def _apply_controls(self, move):
        m = (move or "").strip()
        if not m or m == "stay":
            return
        if m.startswith("ctrl:"):
            self._apply_ctrl(m[5:])
            return
        if m == "throttle up":
            self.booster.kb["throttle"] += 0.12
            self.ship.kb["throttle"] += 0.12
        elif m == "throttle down":
            self.booster.kb["throttle"] -= 0.12
            self.ship.kb["throttle"] -= 0.12
        elif m == "engines up":
            self._bump_engines(1)
        elif m == "engines down":
            self._bump_engines(-1)
        elif m == "target catch":
            self.booster.target = "catch"
            self.ship.target = "catch"
        elif m == "target splash":
            self.booster.target = "splash"
            self.ship.target = "splash"
        else:
            for token in m.split():
                v = self._active()
                if token == "up":
                    v.kb["pitch"] += 0.10
                elif token == "down":
                    v.kb["pitch"] -= 0.10
                elif token == "left":
                    v.kb["yaw"] -= 0.10
                elif token == "right":
                    v.kb["yaw"] += 0.10

    def _bump_engines(self, d):
        for v in (self.booster, self.ship):
            v.engines = clamp(v.engines + d, 1, v.max_engines)

    def _active(self):
        if self.booster.resolved:
            return self.ship
        return self.booster

    def _apply_ctrl(self, body):
        v = self._active()
        if body == "clear":
            for veh in (self.booster, self.ship):
                for key in ("pitch", "yaw", "flap", "throttle"):
                    veh.man.pop(key, None)
            return
        for p in body.split(","):
            if not p or "=" not in p:
                continue
            k, val = p.split("=", 1)
            if k == "p":
                try:
                    v.man["pitch"] = clamp(float(val), -1.0, 1.0)
                except ValueError:
                    pass
            elif k == "y":
                try:
                    v.man["yaw"] = clamp(float(val), -1.0, 1.0)
                except ValueError:
                    pass
            elif k == "f":
                try:
                    v.man["flap"] = clamp(float(val), -1.0, 1.0)
                except ValueError:
                    pass
            elif k == "t":
                try:
                    v.man["throttle"] = clamp(float(val), 0.0, 1.0)
                except ValueError:
                    pass
            elif k == "e":
                try:
                    n = clamp(int(round(float(val))), 1, 3)
                    self.booster.man["engines"] = n
                    self.ship.man["engines"] = n
                except ValueError:
                    pass
            elif k == "g":
                if val in ("catch", "splash"):
                    self.booster.man["target"] = val
                    self.ship.man["target"] = val

    def step(self, action):
        move = action.move
        self._apply_controls(move)
        dt = 0.0 if move.startswith("ctrl:") else self._current_warp()
        if dt > 0:
            self._advance(dt)
        self.last = move
        return self._observe(move), True, None

    def _advance(self, dt):
        n = max(1, int(round(dt / SUB)))
        h = dt / n
        for _ in range(n):
            self.t += h
            self.stage_t += h
            self._world(h)

    def _world(self, h):
        if self.stage == "launch":
            self._launch(h)
        else:
            self._advance_booster(h)
            self._advance_ship(h)
        for p in self.pieces:
            self._piece(p, h)
        if self.booster.resolved and self.ship.resolved:
            self.stage = "done"

    def _launch(self, h):
        p = clamp(self.t / LAUNCH_T, 0.0, 1.0)
        x = X_SEP * p * p
        y = Y_SEP * p * p
        self.stack = (x, y, 0.0)
        self.stack_vel = (2.0 * X_SEP * p / LAUNCH_T, 2.0 * Y_SEP * p / LAUNCH_T, 0.0)
        for v in (self.booster, self.ship):
            v.pos = self.stack
            v.vel = self.stack_vel
            v.pitch = 0.0
            v.yaw = 0.0
            v.throttle = 1.0
            v.burn = 1.0
        if p >= 1.0:
            self.booster.phase = "return"
            self.booster.pt = 0.0
            self.ship.phase = "ascend"
            self.ship.pt = 0.0
            self.stage = "flight"

    def _advance_booster(self, h):
        v = self.booster
        if v.resolved:
            return
        if v.phase == "return":
            v.pt += h
            self._booster_return(v)
        elif v.phase == "landing":
            self._guide_landing(v, h)
            self._integrate(v, h)
            self._collide(v)
            self._resolve_landing(v)

    def _advance_ship(self, h):
        v = self.ship
        if v.resolved:
            return
        if v.phase == "ascend":
            v.pt += h
            self._ship_ascend(v)
        elif v.phase == "loiter":
            if self.booster.resolved:
                v.phase = "deorbit"
                v.pt = 0.0
            else:
                self._ship_loiter(v)
        elif v.phase == "deorbit":
            v.pt += h
            self._ship_deorbit(v)
        elif v.phase == "reentry":
            v.pt += h
            self._ship_reentry(v)
        elif v.phase == "flip":
            v.g_pitch = 0.0
            v.g_yaw = 0.0
            v.g_throttle = 0.0
            v.g_flap = 0.0
            self._apply_manual(v)
            self._integrate(v, h)
            if abs(v.pitch) < 12.0:
                v.phase = "landing"
                v.pt = 0.0
        elif v.phase == "landing":
            self._guide_landing(v, h)
            self._integrate(v, h)
            self._collide(v)
            self._resolve_landing(v)

    def _ship_reentry(self, v):
        tau = clamp(v.pt / REENTRY_T, 0.0, 1.0)
        s = smoothstep(tau)
        x = ENTRY_X * (1.0 - s)
        y = ENTRY_Y + (1000.0 - ENTRY_Y) * s
        v.pos = (x, y, 0.0)
        v.vel = (0.0, -40.0 - 90.0 * s, 0.0)
        v.pitch = 90.0
        v.yaw = 0.0
        v.throttle = 0.0
        v.burn = 0.0
        v.heat = clamp(math.sin(math.pi * clamp(tau, 0.0, 1.0)) * 1.2, 0.0, 1.0)
        if tau >= 1.0:
            v.pos = (0.0, 1000.0, 0.0)
            v.vel = (0.0, -40.0, 0.0)
            v.pitch = 90.0
            v.phase = "flip"
            v.pt = 0.0

    def _booster_return(self, v):
        tau = clamp(v.pt / RETURN_T, 0.0, 1.0)
        if tau < 0.35:
            v.pitch = 0.0 + 180.0 * smoothstep(tau / 0.35)
            v.burn = 0.9 * smoothstep(tau / 0.15)
            v.throttle = 0.85
        elif tau < 0.6:
            v.pitch = 180.0
            v.burn = 0.0
            v.throttle = 0.0
        elif tau < 0.82:
            v.pitch = 180.0 * (1.0 - smoothstep((tau - 0.6) / 0.22))
            v.burn = 0.8 * smoothstep((tau - 0.6) / 0.08)
            v.throttle = 0.75
        else:
            v.pitch = 0.0
            v.burn = 0.6
            v.throttle = 0.5
        s = smoothstep(tau)
        x = X_SEP * (1.0 - s)
        y = Y_SEP + (0.0 - Y_SEP) * s
        arc = 26000.0 * math.sin(math.pi * clamp(tau, 0.0, 1.0))
        y = y + arc * (1.0 if tau < 0.55 else max(0.0, 1.0 - (tau - 0.55) / 0.45))
        v.pos = (x, y, 0.0)
        v.vel = (-10.0 * s * 3.0 - 8.0, -70.0 * s, 0.0)
        if tau >= 1.0:
            v.pos = (0.0, 2200.0, 0.0)
            v.vel = (-6.0, -70.0, 0.0)
            v.pitch = 0.0
            v.burn = 0.0
            v.throttle = 0.0
            v.phase = "landing"
            v.pt = 0.0

    def _ship_ascend(self, v):
        p = clamp(v.pt / 30.0, 0.0, 1.0)
        x = X_SEP + (ENTRY_X - X_SEP) * p
        y = Y_SEP + (200000.0 - Y_SEP) * p
        v.burn = 1.0
        v.throttle = 1.0
        v.pitch = 0.0
        v.pos = (x, y, 0.0)
        if p >= 1.0:
            v.phase = "loiter"
            v.pt = 0.0

    def _ship_loiter(self, v):
        v.pos = (ENTRY_X + math.sin(self.t * 0.02) * 8000.0, 200000.0, 0.0)
        v.burn = 0.0
        v.throttle = 0.0
        v.pitch = 0.0

    def _ship_deorbit(self, v):
        p = clamp(v.pt / 18.0, 0.0, 1.0)
        x = ENTRY_X
        y = 200000.0 + (ENTRY_Y - 200000.0) * p
        v.burn = 0.9
        v.throttle = 0.9
        v.pitch = 180.0
        v.pos = (x, y, 0.0)
        if p >= 1.0:
            v.pos = (ENTRY_X, ENTRY_Y, 0.0)
            v.vel = (0.0, -60.0, 0.0)
            v.pitch = 90.0
            v.yaw = 0.0
            v.throttle = 0.0
            v.burn = 0.0
            v.phase = "reentry"
            v.pt = 0.0

    def _current_warp(self):
        if self.stage == "launch":
            return 0.12
        if self.stage == "done":
            return 0.0
        v = self._active()
        return {
            "return": 0.6,
            "landing": 0.05,
            "ascend": 1.2,
            "loiter": 2.5,
            "deorbit": 0.8,
            "reentry": 0.14,
            "flip": 0.05,
        }.get(v.phase, 0.05)

    def _integrate(self, v, h):
        rho = density(v.pos[1])
        wind = self._wind_vec(v.pos[1])
        vrel = (v.vel[0] - wind[0], v.vel[1] - wind[1], v.vel[2] - wind[2])
        sp = vlen(vrel)
        n = v.nose()
        b = v.belly()
        thrust = v.throttle * v.engines * v.f_engine / v.mass
        ax = n[0] * thrust
        ay = n[1] * thrust - G * (RE / (RE + v.pos[1])) ** 2
        az = n[2] * thrust
        cd = v.cdrag()
        drag = rho * sp * cd
        ax -= drag * vrel[0]
        ay -= drag * vrel[1]
        az -= drag * vrel[2]
        lift = rho * sp * v.flap * 0.0004
        ax += b[0] * lift
        ay += b[1] * lift
        az += b[2] * lift
        rate = 15.0 + 70.0 * (rho / RHO0) + 9.0 * v.throttle * v.engines
        dth = clamp(v.g_throttle - v.throttle, -1.6 * h, 1.6 * h)
        v.throttle = clamp(v.throttle + dth, 0.0, 1.0)
        dp = clamp(v.g_pitch - v.pitch, -rate * h, rate * h)
        v.pitch += dp
        dy = clamp(v.g_yaw - v.yaw, -rate * 0.8 * h, rate * 0.8 * h)
        v.yaw += dy
        df = clamp(v.g_flap - v.flap, -2.0 * h, 2.0 * h)
        v.flap = clamp(v.flap + df, -1.0, 1.0)
        v.vel = (v.vel[0] + ax * h, v.vel[1] + ay * h, v.vel[2] + az * h)
        v.pos = (v.pos[0] + v.vel[0] * h, v.pos[1] + v.vel[1] * h, v.pos[2] + v.vel[2] * h)
        decay = math.exp(-h / 0.4)
        v.kb["pitch"] *= decay
        v.kb["yaw"] *= decay
        v.kb["throttle"] *= decay

    def _apply_manual(self, v):
        if "pitch" in v.man:
            v.g_pitch = clamp(v.g_pitch - v.man["pitch"] * 40.0, -180.0, 180.0)
        if "yaw" in v.man:
            v.g_yaw = clamp(v.g_yaw + v.man["yaw"] * 25.0, -90.0, 90.0)
        if "flap" in v.man:
            v.g_flap = clamp(v.man["flap"], -1.0, 1.0)
        if "throttle" in v.man:
            v.g_throttle = clamp(v.man["throttle"], 0.0, 1.0)
        if "engines" in v.man:
            v.engines = clamp(int(v.man["engines"]), 1, v.max_engines)
        if "target" in v.man:
            v.target = v.man["target"]

    def _guide_landing(self, v, h):
        ty = CATCH_Y if v.target == "catch" else 0.0
        tx, tz = self._target_point(v)
        x, y, z = v.pos
        vx, vy, vz = v.vel
        agl = max(0.5, y - ty)
        need = G + max(0.0, vy * vy) / (2.0 * agl)
        v.g_throttle = clamp(need / max(v.max_accel(), 1e-6), 0.0, 1.0)
        kp, kd = 0.05, 1.2
        ax = kp * (tx - x) - kd * vx
        az = kp * (tz - z) - kd * vz
        v.g_pitch = clamp(math.degrees(clamp(ax / G, -0.3, 0.3)) - clamp(v.kb["pitch"], -1.0, 1.0) * 30.0, -25.0, 25.0)
        v.g_yaw = clamp(math.degrees(clamp(az / G, -0.3, 0.3)) - clamp(v.kb["yaw"], -1.0, 1.0) * 30.0, -25.0, 25.0)
        v.g_throttle = clamp(v.g_throttle + v.kb["throttle"], 0.0, 1.0)
        v.g_flap = 0.0
        self._apply_manual(v)

    def _collide(self, v):
        if v.resolved:
            return
        x, y, z = v.pos
        sp = vlen(v.vel)
        dxy = math.hypot(x, z)
        if dxy < TOWER_R + 4.0 and 0.0 < y < TOWER_H:
            self._crash(v, "flew into the tower")
            self.tower_damaged = True
            self.tower_lean = 0.14
            return
        if 0.0 <= x <= 32.0 and abs(z) <= 14.0 and 108.0 <= y <= 124.0 and sp > 18.0:
            self._crash(v, "slammed into the arms")
            self.tower_damaged = True
            self.tower_lean = 0.1

    def _target_point(self, v):
        if v.target == "catch":
            return (CATCH_X, CATCH_Z)
        return (SPLASH_X, self.splash_z)

    def _resolve_landing(self, v):
        if v.resolved:
            return
        ty = CATCH_Y if v.target == "catch" else 0.0
        tx, tz = self._target_point(v)
        x, y, z = v.pos
        vx, vy, vz = v.vel
        dx = x - tx
        dz = z - tz
        dxy = math.sqrt(dx * dx + dz * dz)
        if v.target == "catch":
            if y <= ty + 1.5 and vy > -SAFE_VDOWN:
                if dxy < 8.0 and abs(vz) < 3.0 and abs(vx) < 3.0 and abs(v.pitch) < 15.0:
                    self._catch(v)
                else:
                    self._crash(v, "missed the arms")
            elif y <= 0.0:
                self._crash(v, "hit the ground")
        else:
            if y <= 0.5 and vy > -SAFE_VDOWN and x > WATER_X:
                self._splash(v)
            elif y <= 0.5 and vy > -SAFE_VDOWN and x <= WATER_X:
                if abs(v.pitch) < 20.0:
                    v.resolved = True
                    v.splashed = True
                    v.outcome = v.name + " touched down on the pad"
                    self.outcomes.append(v.outcome)
                else:
                    self._crash(v, "toppled on the pad")
            elif y <= 0.0:
                self._crash(v, "hit too fast")
        if self.booster.resolved and self.ship.resolved:
            self.stage = "done"
            self.warp = 0.0

    def _catch(self, v):
        v.resolved = True
        v.caught = True
        v.outcome = v.name + " caught by the tower arms"
        self.outcomes.append(v.outcome)
        self.reward += 0.5
        v.pos = (CATCH_X, CATCH_Y, CATCH_Z)
        v.vel = (0.0, 0.0, 0.0)

    def _splash(self, v):
        v.resolved = True
        v.splashed = True
        v.outcome = v.name + " splashed down gently in the water"
        self.outcomes.append(v.outcome)
        self.reward += 0.3
        v.pos = (v.pos[0], 0.0, v.pos[2])
        v.vel = (0.0, 0.0, 0.0)

    def _crash(self, v, why):
        v.resolved = True
        v.broken = True
        v.outcome = v.name + " broke apart (" + why + ")"
        self.outcomes.append(v.outcome)
        self.reward -= 0.5
        self._spawn_pieces(v)

    def _spawn_pieces(self, v):
        n = int(self.rng.uniform(7, 13))
        for _ in range(n):
            ang = self.rng.uniform(0.0, math.tau)
            up = self.rng.uniform(0.3, 1.0)
            spd = self.rng.uniform(6.0, 30.0)
            self.pieces.append(
                {
                    "pos": (v.pos[0], v.pos[1], v.pos[2]),
                    "vel": (
                        math.cos(ang) * spd,
                        up * spd,
                        math.sin(ang) * spd,
                    ),
                    "size": self.rng.uniform(2.5, 7.5),
                    "color": v.color,
                }
            )

    def _piece(self, p, h):
        x, y, z = p["pos"]
        vx, vy, vz = p["vel"]
        rho = density(y)
        sp = math.sqrt(vx * vx + vy * vy + vz * vz)
        drag = rho * sp * 0.004
        vx -= drag * vx * h
        vy -= (G + drag * vy) * h
        vz -= drag * vz * h
        x += vx * h
        y += vy * h
        z += vz * h
        if y <= 0.0:
            y = 0.0
            vy = 0.0
            vx *= 0.4
            vz *= 0.4
        p["pos"] = (x, y, z)
        p["vel"] = (vx, vy, vz)

    def _observe(self, move):
        active = self._active()
        rho = density(active.pos[1])
        wind = self._wind_vec(active.pos[1])
        wind_spd = math.hypot(wind[0], wind[2])
        scene = self._scene()
        horizon = self._horizon(active.pos[1])
        altitude = max(0.0, active.pos[1])
        speed = vlen(active.vel)
        vs = active.vel[1]
        phase_label = self._phase_label()
        wind_label = self._wind_label(wind_spd)
        obs = base_observation(
            reward=clamp(self.reward, -1.0, 1.0),
            done=(self.stage == "done"),
            legal_moves=[],
            last_move=move,
            scene=scene,
            horizon=horizon,
            atmo=clamp(rho / RHO0, 0.0, 1.0),
            altitude=int(altitude),
            speed=int(speed),
            vertical=int(vs),
            throttle=round(active.throttle, 2),
            engines=active.engines,
            flap=round(active.flap, 2),
            target=active.target,
            air=round(clamp(rho / RHO0, 0.0, 1.0), 2),
            wind=wind_label,
            wind_deg=int(self.wind_dir),
            phase=phase_label,
            active=active.name,
            booster=self._status(self.booster),
            ship=self._status(self.ship),
            outcomes=list(self.outcomes),
            tower_damaged=self.tower_damaged,
            inputs=self._inputs(),
            note=self._note(),
        )
        return obs

    def _status(self, v):
        if v.resolved:
            return v.outcome
        if v.phase == "ascend":
            return "to orbit"
        if v.phase == "loiter":
            return "in orbit"
        if v.phase == "deorbit":
            return "deorbiting"
        if v.phase == "reentry":
            return "reentering"
        if v.phase == "flip":
            return "flipping"
        if v.phase == "landing":
            return "landing"
        if v.phase == "return":
            return "returning"
        return "on the pad"

    def _phase_label(self):
        if self.stage == "launch":
            return "Liftoff"
        if self.stage == "done":
            return "Mission complete"
        v = self._active()
        return {
            "return": "Booster boostback",
            "landing": ("Booster landing" if v.kind == "booster" else "Ship landing"),
            "ascend": "Ship to orbit",
            "loiter": "Ship in orbit",
            "deorbit": "Deorbit burn",
            "reentry": "Ship reentry",
            "flip": "Ship flip",
        }.get(v.phase, v.phase)

    def _wind_label(self, spd):
        wd = self.wind_dir % 360.0
        dirs = ["north", "north-east", "east", "south-east", "south", "south-west", "west", "north-west"]
        idx = int(round(wd / 45.0)) % 8
        return f"{int(spd)} m/s from the {dirs[idx]}"

    def _horizon(self, y):
        h = max(0.0, y)
        dip = math.acos(clamp(RE / (RE + h), -1.0, 1.0))
        return clamp(0.46 + dip * 1.6, 0.46, 0.9)

    def _note(self):
        if self.stage == "done":
            return "Mission over. Press R to reset."
        if self.stage == "launch":
            return "Starship lifting off. Watch the boostback."
        v = self._active()
        if v.kind == "booster":
            return "Booster returns. Steer it to the tower arms or a splash."
        return "Ship reenters belly-first, then flips to land."

    def _inputs(self):
        return [
            key_input(
                "Aim",
                "WASD or arrows pitch and yaw",
                {
                    "w": "up", "ArrowUp": "up",
                    "s": "down", "ArrowDown": "down",
                    "a": "left", "ArrowLeft": "left",
                    "d": "right", "ArrowRight": "right",
                },
                hold=True,
                every_ms=50,
                idle="stay",
            ),
            key_input("Throttle", "E more thrust, Q less", {"e": "throttle up", "q": "throttle down"}, hold=True),
            key_input("Engines", "X more engines, Z fewer", {"x": "engines up", "z": "engines down"}),
            key_input("Landing", "C catch on tower, V splash down", {"c": "target catch", "v": "target splash"}),
        ]

    def _scene(self):
        cam_target = self._active().pos
        cam = vadd(cam_target, (-320.0, 210.0, 520.0))
        shapes = []
        self._water(shapes, cam)
        self._pad(shapes, cam)
        self._tower(shapes, cam)
        for p in self.pieces:
            self._shape_piece(shapes, cam, p)
        if not self.booster.resolved:
            self._shape_vehicle(shapes, cam, self.booster)
        if not self.ship.resolved and self.stage != "launch":
            self._shape_vehicle(shapes, cam, self.ship)
        shapes.sort(key=lambda s: s.get("d", 0.0), reverse=True)
        return shapes

    def _cam_space(self, cam, p):
        forward = vnorm(vsub(self._active().pos, cam))
        world_up = (0.0, 1.0, 0.0)
        right = vnorm(vcross(forward, world_up))
        up = vcross(right, forward)
        d = vsub(p, cam)
        return (vdot(d, right), vdot(d, up), vdot(d, forward))

    def _project_cs(self, cs):
        xc, yc, zc = cs
        z = zc if zc > NEAR else NEAR
        sx = CX + FOCAL * xc / z
        sy = CY - FOCAL * yc / z
        return (clamp(sx, -4000.0, 4000.0), clamp(sy, -4000.0, 4000.0))

    def _clip_poly(self, pts_cs):
        out = []
        n = len(pts_cs)
        for i in range(n):
            a = pts_cs[i]
            b = pts_cs[(i + 1) % n]
            ain = a[2] >= NEAR
            bin_ = b[2] >= NEAR
            if ain:
                out.append(a)
            if ain != bin_:
                t = (NEAR - a[2]) / (b[2] - a[2] - 1e-9)
                out.append((a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, NEAR))
        return out

    def _clip_line(self, a, b):
        ain = a[2] >= NEAR
        bin_ = b[2] >= NEAR
        if ain and bin_:
            return [a, b]
        if ain != bin_:
            t = (NEAR - a[2]) / (b[2] - a[2] - 1e-9)
            mid = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, NEAR)
            return [a, mid] if ain else [mid, b]
        return []

    def _push(self, shapes, cam, kind, pts, fill, stroke, width, op=1.0):
        if not pts:
            return
        cs = [self._cam_space(cam, p) for p in pts]
        if kind == "circle":
            if cs[0][2] < NEAR:
                return
            sx, sy = self._project_cs(cs[0])
            px = [[sx, sy]]
            depth = cs[0][2]
        elif kind == "line":
            clipped = self._clip_line(cs[0], cs[1])
            if not clipped:
                return
            px = [[self._project_cs(c)[0], self._project_cs(c)[1]] for c in clipped]
            depth = (clipped[0][2] + clipped[-1][2]) / 2.0
        else:
            clipped = self._clip_poly(cs)
            if len(clipped) < 3:
                return
            px = [[self._project_cs(c)[0], self._project_cs(c)[1]] for c in clipped]
            depth = sum(c[2] for c in clipped) / len(clipped)
        shapes.append(
            {
                "t": kind,
                "p": px,
                "f": fill,
                "s": stroke,
                "w": width,
                "o": op,
                "d": depth,
            }
        )

    def _water(self, shapes, cam):
        y = 0.0
        pts = [
            (-1200.0, y, -1500.0),
            (3000.0, y, -1500.0),
            (3000.0, y, 1500.0),
            (-1200.0, y, 1500.0),
        ]
        self._push(shapes, cam, "poly", pts, "#0b3a63", None, 0, 0.75)

    def _pad(self, shapes, cam):
        y = 0.02
        pts = [
            (-70.0, y, -70.0),
            (140.0, y, -70.0),
            (140.0, y, 70.0),
            (-70.0, y, 70.0),
        ]
        self._push(shapes, cam, "poly", pts, "#3f3f46", None, 0, 1.0)

    def _tower(self, shapes, cam):
        lean = self.tower_lean
        top = (TOWER_H * math.sin(lean), TOWER_H * math.cos(lean), 0.0)
        base = (0.0, 0.0, 0.0)
        self._push(shapes, cam, "line", [base, top], None, "#78716c", 7.0, 1.0)
        self._push(shapes, cam, "line", [base, top], None, "#57534e", 3.0, 1.0)
        arm_y = CATCH_Y
        a1 = [
            (-6.0, arm_y, -8.0),
            (30.0, arm_y, -8.0),
        ]
        a2 = [
            (-6.0, arm_y, 8.0),
            (30.0, arm_y, 8.0),
        ]
        self._push(shapes, cam, "line", a1, None, "#292524", 4.0, 1.0)
        self._push(shapes, cam, "line", a2, None, "#292524", 4.0, 1.0)
        self._push(shapes, cam, "circle", [(CATCH_X, CATCH_Y, CATCH_Z)], "#84cc16", None, 3.0, 0.9)

    def _shape_vehicle(self, shapes, cam, v):
        n = v.nose()
        half = v.length / 2.0
        tail = vsub(v.pos, vmul(n, half))
        nose = vadd(v.pos, vmul(n, half))
        self._push(shapes, cam, "line", [tail, nose], v.color, "#1c1917", 9.0, 1.0)
        self._push(shapes, cam, "line", [tail, nose], None, "#f4f4f5", 4.5, 1.0)
        if v.kind == "ship" and v.heat > 0.05:
            b = v.belly()
            heat = v.heat
            bp = vadd(v.pos, vmul(b, 6.0))
            self._push(shapes, cam, "circle", [bp], "#f97316", "#fbbf24", 4.0, clamp(heat, 0.2, 1.0))
            bp2 = vadd(v.pos, vmul(b, 10.0))
            self._push(shapes, cam, "circle", [bp2], "#fbbf24", None, 2.0, clamp(heat * 0.8, 0.1, 1.0))
        if v.burn > 0.01:
            flen = v.length * (0.5 + 0.9 * v.burn)
            fdir = vmul(n, -1.0)
            nozzle = tail
            tip = vadd(nozzle, vmul(fdir, flen))
            right = vnorm(vcross(fdir, (0.0, 0.0, 1.0)))
            w = 4.0 + 4.0 * v.burn
            p1 = vadd(nozzle, vmul(right, w))
            p2 = vadd(nozzle, vmul(right, -w))
            self._push(shapes, cam, "poly", [p1, p2, tip], "#fbbf24", None, 0, 0.95)
            tip2 = vadd(nozzle, vmul(fdir, flen * 0.5))
            self._push(shapes, cam, "poly", [p1, p2, tip2], "#ffffff", None, 0, 0.8)

    def _shape_piece(self, shapes, cam, p):
        self._push(shapes, cam, "circle", [p["pos"]], p["color"], None, 1.2, 1.0)