Searchlight: Track a Vessel
This is the one where the program doesn’t exit.
Everything we’ve written so far computes something, writes it, and leaves. This time we’re building a
tracker: a little program that watches two moving ships and keeps one pointed at the other,
tick after tick, for as long as you let it run. Point Hunter at Polaris, switch on Hunter’s
spotlight, narrow the beam, and let the flight computer do the swinging: a searchlight that follows
its target across the sky.
Along the way we’ll meet the thing every long-running flight program is made of: the control loop: read, decide, write, wait, repeat: and the two habits that keep a loop honest (pace it in simulation time, and hold when the sim isn’t in a state you want to command).
Before you start
Section titled “Before you start”-
Point at Parent: this page is that one, plus a loop. We reuse its whole idea (aim direction →
body_to_cci→ctl/attitude_target) without re-explaining it. -
Basic gatOS I/O Toolkit: we import from both modules (
gatos_io.pyandgatos_frames.py). Have them in~/tutorials. -
Teleport a Fleet into Orbit (optional but perfect) : the easiest way to stage this demo is to drop two ships into the same orbit a few hundred meters apart. One command, and you’ve got a source and a target.
The big idea: two positions, one subtraction
Section titled “The big idea: two positions, one subtraction”In Point at Parent the aim direction was a gift : the CCI position already was the arrow we needed, just backwards. Aiming at another vessel is one small step up: read both ships’ positions and subtract.
position/cci is “where am I, measured from the parent body’s center.” Two vessels orbiting the
same body share that origin and those axes: so the arrow from the source ship to the target
ship is simply:
Same frame, same origin, one subtraction. That’s the entire geometry of a searchlight.
But a one-shot write of that aim goes stale immediately: both ships are doing kilometers per second, each on its own orbit, so the arrow between them swings constantly. The fix is the loop: recompute the arrow and hand the flight computer a fresh quaternion every half sim-second or so. The autopilot smooths out the in-between.
The light is a machine, not a math problem
Section titled “The light is a machine, not a math problem”The pointing is only half the job. The source ship’s spotlight is a part, and parts are physical: many light parts carry a little actuate animation: a motorized sweep that stows, deploys, or swivels the lamp head. gatOS exposes that animation as a file:
lights/<n>/goal← a fraction0..1over the part’s mechanical sweep
Write 0 and the animation runs to one end (stowed); write 1 and it runs to the other; write
0.62 and it parks 62% of the way through. Here’s the catch: where in that sweep the beam ends up
parallel to the ship’s nose is a fact about the part’s 3D model: nothing in /sim can tell you.
Maybe it’s 1.0 (fully deployed = straight ahead). Maybe the arm overshoots and it’s 0.85.
So the program takes it as a calibration constant: a --aim-goal flag you tune once by eye and
then reuse forever. Aiming the ship is geometry; aiming the lamp on the ship is calibration.
Real spacecraft engineering in one flag.
The program
Section titled “The program”Save this as ~/tutorials/searchlight.py, next to your toolkit modules:
#!/usr/bin/env python3# Keep <source>'s nose - and its spotlight - locked onto <target>. Runs until Ctrl-C.# e.g. python3 searchlight.py Hunter Polaris --aim-goal 1.0 --beam 6import argparse, sys, timefrom gatos_io import read, read_scalar, read_vec, write, write_vecfrom gatos_frames import Vec3, sub, norm, body_to_cci
ap = argparse.ArgumentParser(description="Track a target vessel with the flight computer + a spotlight.")ap.add_argument("source", help="the vessel that does the pointing")ap.add_argument("target", help="the vessel to track")ap.add_argument("--light", type=int, default=0, help="light module index on the source (default 0)")ap.add_argument("--aim-goal", type=float, default=1.0, help="CALIBRATION: the animation goal 0..1 where the beam runs parallel to the nose")ap.add_argument("--beam", type=float, default=6.0, help="spotlight outer cone half-angle, degrees")ap.add_argument("--period", type=float, default=0.5, help="re-aim interval, sim-seconds")args = ap.parse_args()
src = f"/sim/vessels/by-id/{args.source}"tgt = f"/sim/vessels/by-id/{args.target}"
# CCI is "about MY parent" - two positions only share a frame if the parents match.if read(f"{src}/parent") != read(f"{tgt}/parent"): sys.exit(f"{args.source} and {args.target} orbit different bodies; their CCI positions aren't comparable")
# Park until sim time advances `seconds` - warp-correct, and sleeps while paused.def sleep_sim(seconds: float) -> None: write("/sim/time/alarm", read_scalar("/sim/time/ut") + seconds) read_scalar("/sim/time/alarm") # this read blocks until the alarm fires
# A reason to skip commanding this tick, or None to fly.def held() -> str | None: if read_scalar("/sim/time/sim_dt") == 0.0: return "paused" if read_scalar("/sim/time/warp") > 1.0: return "warping" return None
# --- one-time light setup: on, narrow beam, aim animation to the calibrated mark ---light = f"{src}/lights/{args.light}"try: write(f"{light}/on", 1) write(f"{light}/outer_angle", args.beam) # a tight spot cone reads as a "beam" in-gameexcept OSError: sys.exit(f"{args.source} has no light module {args.light} - try: ls {src}/lights")try: write(f"{light}/goal", args.aim_goal) # drive the deploy/aim sweep to the calibrated spotexcept OSError: print("this light has no aim animation (no goal file) - fixed mount, carrying on", file=sys.stderr)
# --- the tracking loop: read -> gate -> aim -> pace, forever -----------------------print(f"tracking {args.target} - Ctrl-C to stop", file=sys.stderr)try: while True: if (reason := held()) is not None: print(f"holding ({reason})", file=sys.stderr) time.sleep(0.5) # wall-clock nap: no commands while held continue s: Vec3 = read_vec(f"{src}/position/cci") t: Vec3 = read_vec(f"{tgt}/position/cci") aim = sub(t, s) # the arrow source -> target, in one shared frame # Roll reference = the source's own radial line, so the ship tracks with a # steady "belly-down" roll instead of corkscrewing as the aim swings. write_vec(f"{src}/ctl/attitude_target", body_to_cci(aim, s)) print(f"range to {args.target}: {norm(aim):,.0f} m", file=sys.stderr) sleep_sim(args.period)except KeyboardInterrupt: write(f"{src}/ctl/attitude_mode", "manual") # hand the ship back to its pilot write(f"{light}/on", 0) # lights out print("\nreleased", file=sys.stderr)The loop body is the same four verbs as ever: but arranged in the shape every long-running flight program shares:
- Read both positions. Two
read_veccalls, one shared frame (we checked the parents match up front, before touching anything). - Gate first, command second. If the sim is paused or warping, we print a banner, nap, and write nothing. A setpoint computed from a paused world is stale the moment time resumes; a per-tick loop at 1000× warp is just noise. Holding costs nothing: the flight computer keeps flying the last good setpoint.
- Aim:
subfor the arrow,body_to_ccifor the quaternion, one write toctl/attitude_target. Identical machinery to Point at Parent; only the aim vector is new. - Pace in sim time.
sleep_simarms/sim/time/alarmand blocks on the read until the simulation clock reaches it. Notime.sleep(0.5)guesswork against a clock the game can pause : except in the held branch, where wall-clock is exactly what we want (sim time may not be moving at all).
And when you Ctrl-C it, the except block cleans up after itself: attitude back to manual, light
off. A tracker you can’t cleanly stop is a haunting, not a program.
Run it
Section titled “Run it”Stage two ships a few hundred meters apart (this is exactly what the teleport tutorial was built for), then start the tracker:
# set the stage: two ships, same 200 km orbit, 400 m apartpython3 teleport-into-orbit.py --parent Earth --vessel Hunter --vessel Polaris \ --altitude 200000 --spread 400
# and light 'em uppython3 searchlight.py Hunter Polaris --beam 6tracking Polaris - Ctrl-C to stoprange to Polaris: 400 mrange to Polaris: 400 m...Watch Hunter swing its nose onto Polaris and stay there: nudge the camera around and you’ll see
the tight cone of light pinned on the target while both ships fall around the planet together. Pause
the game: the loop banners holding (paused) and goes quiet. Resume: it picks the lock right back
up. That’s the loop earning its keep.
What’s next
Section titled “What’s next”You’ve built your first program that runs: a loop that shares the cockpit with the simulation,
holds when it should, and cleans up when it’s done. That skeleton is most of what a real autopilot
is. From here, two natural directions: put numbers under the loop (orbital math: circular
speeds, vis-viva, time-to-apoapsis) so it can decide when to act, or make it react to events
(/sim/events) instead of polling. Either way, the searchlight pattern comes with you.