# Space

![Space](space.png)

Source: [`examples/space`](https://github.com/matjam/bunyip/tree/main/examples/space) (main.go)

This example flies a ship through a made-up star system. A star, seven
planets, a dozen moons, three hundred asteroids and a comet each follow
an exact Kepler orbit around their primary. The ship is a free body
under gravity plus its own thrust, and its predicted path is drawn ahead
of it in the frame of whatever it is orbiting. The camera can be locked
to any body, which moves the floating origin so the scene stays precise
at any distance from the star.

Nothing here is our solar system. Masses, distances and the
gravitational constant are game units, with `G = 1`, which is what the
[orbit](../pkg/orbit.md) package is for: the real constants live in
[orbit/sol](../pkg/orbit/sol.md) and are not used here. The other
packages are [ecs](../pkg/ecs.md) for the bodies and the orbit system,
[gfx](../pkg/gfx.md) for the scene, and [ui](../pkg/ui.md) for the
panel. The guides are [Orbits and space](../guides/space.md) and
[The interface](../guides/ui.md).

Run it with:

```bash
go run ./examples/space -seconds 3 -shot out.png
```

`-focus Name` starts focused on a named body, `-dist N` sets the
starting camera distance and `-warp N` the starting time warp. Drag
orbits the camera and the scroll wheel zooms from the hull out to the
whole system. Tab cycles the focus. W and S thrust prograde and
retrograde, A and D sideways, Q and E out of the orbital plane. The two
sliders set thrust and time warp; Escape quits.

## The system's description

`look` is the render component: a name, a radius, a colour, a glow, and
two flags saying whether to draw the orbit and whether Tab can focus it.
`planet` and `moon` are plain data used once, in `Init`, to build the
world; they are not components.

The comment on `starMass` is the design note that matters for playing
it: with `G = 1` and this mass the inner planet takes two minutes to go
round and the outer one an hour, which is why the time warp slider
exists. The elements are the classical six: semi-major axis,
eccentricity, inclination, longitude of the ascending node, argument of
periapsis and true anomaly, with the angles in radians.

```go
// look says how to draw a body.
type look struct {
	Name   string
	Radius float32
	Color  gfx.Color
	Glow   float32
	Orbit  bool // draw the orbit as a ring of dots
	Focus  bool // Tab can focus it
}

// planet describes one world of the system and its moons.
type planet struct {
	name       string
	a, e, incl float64
	node, arg  float64
	mass       float64
	radius     float32
	color      gfx.Color
	moons      []moon
}

type moon struct {
	a, incl float64
}

// A calm, spread-out system: with G = 1 and this star mass the inner
// planet takes two minutes to go round and the outer one an hour, so
// the time warp slider matters.
const starMass = 175

var planets = []planet{
	{name: "Ash", a: 40, e: 0.15, incl: 0.02, node: 0.5, arg: 1.0, mass: 0.3, radius: 0.45, color: gfx.RGB(190, 150, 120)},
	{name: "Ferro", a: 70, e: 0.05, incl: 0.03, node: 2.0, arg: 0.4, mass: 0.8, radius: 0.8, color: gfx.RGB(200, 110, 80), moons: []moon{{2.6, 0.1}}},
	{name: "Vessa", a: 110, e: 0.03, incl: 0.01, node: 4.0, arg: 2.2, mass: 1.2, radius: 1.0, color: gfx.RGB(80, 150, 210), moons: []moon{{5.0, 0.05}, {8.0, 0.25}}},
	{name: "Grond", a: 170, e: 0.08, incl: 0.04, node: 1.2, arg: 3.1, mass: 4, radius: 1.7, color: gfx.RGB(200, 170, 120), moons: []moon{{4.5, 0}, {7, 0.1}, {11, 0.3}}},
	{name: "Pell", a: 265, e: 0.12, incl: 0.15, node: 3.5, arg: 0.9, mass: 0.05, radius: 0.3, color: gfx.RGB(160, 160, 170)},
	{name: "Nimbus", a: 380, e: 0.05, incl: 0.02, node: 5.5, arg: 1.7, mass: 8, radius: 2.3, color: gfx.RGB(150, 190, 230), moons: []moon{{5.5, 0.02}, {8.5, 0.05}, {13, 0.1}, {21, 0.4}}},
	{name: "Thule", a: 600, e: 0.2, incl: 0.3, node: 0.3, arg: 2.5, mass: 0.2, radius: 0.45, color: gfx.RGB(200, 220, 240)},
}

const asteroids = 300
```

## The game type

Two cached queries find drawable bodies and bodies on a Kepler orbit;
separate entity handles identify the star and the ship. `focus` is the
list of bodies Tab cycles through and `focused` is the index into it.
The camera is again three numbers, and `stars` is a set of unit
directions used to place the background starfield. `simTime` counts
simulation seconds and `path` keeps the predicted path between the
frames that recompute it.

```go
type game struct {
	seconds    float64
	shot       string
	startFocus string
	startDist  float32
	startWarp  float32

	font     *gfx.Font
	ui       *ui.Context
	world    *ecs.World
	sphere   *gfx.Mesh
	dot      *gfx.Mesh
	bodies   *ecs.Query2[gfx.Transform, look]
	orbits   *ecs.Query3[orbit.Kepler, orbit.Body, look]
	star     ecs.Entity
	ship     ecs.Entity
	focus    []ecs.Entity
	focused  int
	thrust   float32
	warp     float32
	yaw      float32
	pitch    float32
	dist     float32
	lastX    float32
	lastY    float32
	dragging bool
	shotDone bool
	stars    []lin.Vec3 // unit directions of a background starfield
	simTime  float64    // simulation seconds elapsed, for the path's age
	path     pathCache
}
```

## The path cache

Predicting the path integrates the ship a minute or more ahead, which
costs more than the rest of the frame. The path hardly changes between
frames, so the example keeps it in a `pathCache` with what it was
computed from: the time, the body it is drawn around and where that body
was, the thrust, the time warp and the horizon. `stale` forces the next
frame to recompute it.

```go
// pathCache holds the ship's predicted path between the frames that
// recompute it. Predicting integrates the ship minutes ahead, which costs
// more than drawing the whole frame, and the path barely changes from
// one frame to the next, so it is recomputed only when the ship has
// moved one dot along it or an input to the prediction has changed.
type pathCache struct {
	points  []lin.Vec3
	at      float64    // simTime when predicted
	primary ecs.Entity // the body it is drawn around
	anchor  orbit.Vec3 // where that body was then, less the origin
	thrust  orbit.Vec3 // the thrust it assumed
	warp    float32    // the time warp it assumed
	horizon float64    // how far ahead it reaches
	stale   bool       // recompute on the next frame regardless
}
```

## Init: building the system

`orbit.Settings` is a resource: `G` is the gravitational constant in
game units, `TimeScale` is the time warp, `Scale` converts orbit units
to render units and `Substeps` is how finely the integrator runs per
update.

Each body carries an `orbit.Body` with a mass, a `gfx.Transform` that
the orbit system writes, a `look`, and for everything but the ship an
`orbit.Kepler` naming its primary and its elements. A `Kepler` body is
placed analytically on its ellipse rather than integrated, avoiding
accumulated integration drift. Finite-precision arithmetic still limits
very large elapsed times and coordinates.

The asteroids get `orbit.Body{}` with a zero mass. They follow their
orbits but pull on nothing, so three hundred of them cost the ship's
integration nothing.

```go
func (g *game) Init(ctx *engine.Context) error {
	var err error
	if g.font, err = ctx.Gfx.NewFont(goregular.TTF, 14, gfx.FontOptions{}); err != nil {
		return err
	}
	g.ui = ui.New(ctx.Gfx, ui.DarkTheme(g.font))
	sv, si := gfx.SphereMesh(16, 32)
	if g.sphere, err = ctx.Gfx.NewMesh(sv, si); err != nil {
		return err
	}
	dv, di := gfx.SphereMesh(4, 6)
	if g.dot, err = ctx.Gfx.NewMesh(dv, di); err != nil {
		return err
	}
	g.thrust, g.warp = 0.01, 1
	g.yaw, g.pitch, g.dist = 0.6, 0.35, 14
	random := rng.New(11)
	for range 400 {
		g.stars = append(g.stars, lin.V3(random.Between(-1, 1), random.Between(-1, 1), random.Between(-1, 1)).Norm())
	}
	w := ecs.NewWorld()
	g.world = w
	g.bodies = w.Query2[gfx.Transform, look]()
	g.orbits = w.Query3[orbit.Kepler, orbit.Body, look]()
	w.SetResource(orbit.Settings{G: 1, TimeScale: 1, Scale: 1, Substeps: 8})
	g.star = w.SpawnWith(orbit.Body{Mass: starMass}, gfx.Transform{}, look{"Kaal", 4, gfx.RGB(255, 214, 130), 5, false, true})
	var vessa ecs.Entity
	for _, p := range planets {
		e := w.SpawnWith(orbit.Body{Mass: p.mass}, gfx.Transform{}, look{p.name, p.radius, p.color, 0, true, true},
			orbit.Kepler{Primary: g.star, Elements: orbit.Elements{SemiMajorAxis: p.a, Eccentricity: p.e, Inclination: p.incl,
				Node: p.node, ArgPeriapsis: p.arg, TrueAnomaly: float64(random.Float()) * 2 * math.Pi}})
		if p.name == "Vessa" {
			vessa = e
		}
		for i, m := range p.moons {
			w.SpawnWith(orbit.Body{Mass: p.mass * 0.008}, gfx.Transform{},
				look{fmt.Sprintf("%s %s", p.name, []string{"I", "II", "III", "IV"}[i]), 0.12 + 0.05*float32(i), gfx.RGB(190, 190, 200), 0, true, false},
				orbit.Kepler{Primary: e, Elements: orbit.Elements{SemiMajorAxis: m.a, Inclination: m.incl,
					Node: float64(random.Float()) * 6, TrueAnomaly: float64(random.Float()) * 2 * math.Pi}})
		}
	}
	// The belt: massless rocks between Grond and Nimbus. They follow
	// their orbits but pull on nothing, so they cost the ship nothing.
	for range asteroids {
		shade := uint8(110 + random.Intn(60))
		w.SpawnWith(orbit.Body{}, gfx.Transform{}, look{"", random.Between(0.04, 0.12), gfx.RGB(shade, shade, shade-10), 0, false, false},
			orbit.Kepler{Primary: g.star, Elements: orbit.Elements{SemiMajorAxis: float64(random.Between(225, 305)), Eccentricity: float64(random.Between(0, 0.12)),
				Inclination: float64(random.Between(-0.08, 0.08)), Node: float64(random.Float()) * 6, ArgPeriapsis: float64(random.Float()) * 6,
				TrueAnomaly: float64(random.Float()) * 2 * math.Pi}})
	}
	w.SpawnWith(orbit.Body{}, gfx.Transform{}, look{"Comet Iri", 0.1, gfx.RGB(210, 240, 255), 3, true, true},
		orbit.Kepler{Primary: g.star, Elements: orbit.Elements{SemiMajorAxis: 330, Eccentricity: 0.93, Inclination: 0.55, Node: 4.2, ArgPeriapsis: 1.1, TrueAnomaly: 2.4}})
	w.AddSystem("orbits", orbit.System)
```

The comet has an eccentricity of 0.93, which gives the long thin ellipse
that makes the orbit rings worth drawing.

## Placing the ship

The ship needs a position and a velocity, not elements, because it is a
free body. `w.Update(0)` runs the orbit system once with no time step,
which places every Kepler body at its starting element, so Vessa's
position and velocity can be read. `orbit.Elements{...}.State(mu)`
converts elements to a state vector for a given standard gravitational
parameter, and adding that relative state to Vessa's gives a ship in a
low orbit around it, inside its moons.

`orbit.Ship{}` and `orbit.Thrust{}` mark the entity as an integrated
body that accepts thrust. The focus list is then built: the ship and the
star first, then every body whose `look.Focus` is set, so Tab visits
them in a sensible order.

```go
	// The ship starts in a low orbit around Vessa, inside its moons.
	w.Update(0)
	vb, _ := w.Get[orbit.Body](vessa)
	rel := orbit.Elements{SemiMajorAxis: 2.4, TrueAnomaly: math.Pi}.State(1 * vb.Mass)
	g.ship = w.SpawnWith(orbit.Ship{}, orbit.Thrust{}, orbit.Body{Pos: vb.Pos.Add(rel.Pos), Vel: vb.Vel.Add(rel.Vel)}, gfx.Transform{},
		look{"Ship", 0.03, gfx.RGB(255, 255, 255), 2, false, true})
	g.focus = append(g.focus, g.ship, g.star)
	g.bodies.Each(func(e ecs.Entity, _ *gfx.Transform, l *look) {
		if l.Focus && e != g.ship && e != g.star {
			g.focus = append(g.focus, e)
		}
	})
	for i, e := range g.focus {
		if l, ok := w.Get[look](e); ok && l.Name == g.startFocus {
			g.focused = i
		}
	}
	if g.startDist > 0 {
		g.dist = g.startDist
	}
	if g.startWarp > 0 {
		g.warp = g.startWarp
	}
	return nil
}

func (g *game) Shutdown(ctx *engine.Context) {
	g.dot.Destroy()
	g.sphere.Destroy()
	g.font.Destroy()
}
```

## Update: thrust, warp and the floating origin

`g.ui.WantsMouse()` guards the drag, so dragging a slider does not also
turn the camera. That test is the one piece of plumbing an
immediate-mode interface needs from the game.

The thrust directions are derived from the ship's own velocity:
prograde is the velocity normalised, `side` is prograde crossed with a
fixed axis, and `up` completes the set. The keys sum into an
acceleration which is scaled by the thrust slider and written to the
`orbit.Thrust` component; the integrator applies it.

The last step sets `settings.Origin` to the focused body's position.
Everything is then drawn relative to that point, so the coordinates the
renderer sees stay small however far the system extends. Positions in
`orbit` are `float64`, and the origin is what keeps the `float32` render
side precise. After the world updates, `simTime` advances by the
simulation time that update covered. A frame that takes a screenshot
marks the path stale, so the picture always shows a fresh prediction.

```go
func (g *game) Update(ctx *engine.Context) error {
	in := ctx.Input
	if in.KeyPressed(input.KeyEscape) || (g.seconds > 0 && ctx.Time >= g.seconds) {
		ctx.Quit()
	}
	if g.shot != "" && !g.shotDone && (g.seconds == 0 || ctx.Time >= g.seconds/2) {
		ctx.Screenshot(g.shot)
		g.shotDone = true
		g.path.stale = true // a screenshot shows a freshly predicted path
	}
	if in.KeyPressed(input.KeyTab) {
		g.focused = (g.focused + 1) % len(g.focus)
	}
	x, y := in.Mouse()
	if in.MousePressed(input.MouseLeft) && !g.ui.WantsMouse() {
		g.dragging = true
	}
	if in.MouseReleased(input.MouseLeft) {
		g.dragging = false
	}
	if g.dragging {
		g.yaw -= (x - g.lastX) * 0.01
		g.pitch = lin.Clamp(g.pitch+(y-g.lastY)*0.01, -1.5, 1.5)
	}
	g.lastX, g.lastY = x, y
	if _, dy := in.Scroll(); dy != 0 {
		g.dist = lin.Clamp(g.dist*float32(math.Pow(0.88, float64(dy))), 1, 3000)
	}
	w := g.world
	settings := w.Resource[orbit.Settings]()
	settings.TimeScale = float64(g.warp)
	// Thrust along the ship's velocity (prograde), across it, or out of
	// its orbital plane.
	body, _ := w.Get[orbit.Body](g.ship)
	thrust, _ := w.Get[orbit.Thrust](g.ship)
	prograde := body.Vel.Norm()
	side := prograde.Cross(orbit.V3(0, 0, 1)).Norm()
	up := side.Cross(prograde)
	var a orbit.Vec3
	for _, k := range []struct {
		key input.Key
		dir orbit.Vec3
	}{{input.KeyW, prograde}, {input.KeyS, prograde.Mul(-1)}, {input.KeyD, side}, {input.KeyA, side.Mul(-1)}, {input.KeyQ, up}, {input.KeyE, up.Mul(-1)}} {
		if in.KeyDown(k.key) {
			a = a.Add(k.dir)
		}
	}
	thrust.Accel = a.Mul(float64(g.thrust))
	// The focused body sits at the floating origin, so the scene stays
	// precise wherever it is.
	if fb, ok := w.Get[orbit.Body](g.focus[g.focused]); ok {
		settings.Origin = fb.Pos
	}
	w.Update(ctx.Delta)
	g.simTime += ctx.Delta * settings.TimeScale
	return nil
}
```

## The camera

The camera orbits the origin, which is the focused body, with `z` up
because the system's reference plane is x-y, which is the convention the
`orbit` package uses. `Near` is small and `Far` is 8000 so the ship's
hull and the whole system are both in range.

```go
// camera orbits the focused body, which is at the origin, with z up: the
// system's reference plane is x-y.
func (g *game) camera() gfx.Camera {
	cp, sp := float32(math.Cos(float64(g.pitch))), float32(math.Sin(float64(g.pitch)))
	cy, sy := float32(math.Cos(float64(g.yaw))), float32(math.Sin(float64(g.yaw)))
	return gfx.Camera{Position: lin.V3(g.dist*cp*cy, g.dist*cp*sy, g.dist*sp), Up: lin.V3(0, 0, 1), Near: 0.05, Far: 8000}
}
```

## Keeping the predicted path

`predictPath` recomputes the path when there is none yet, when a
screenshot asked for a fresh one, when the body it is drawn around, the
thrust or the time warp changed, when the horizon moved by more than a
twentieth, and when the ship has travelled one of the path's 90 steps
since the last prediction. `orbit.AppendPredictRelative` writes into the
cached slice, so recomputing allocates nothing once the slice has grown.

Between recomputes the points stay where they were computed, relative
to the primary as it was then. The function returns how far the primary
has moved against the floating origin since, and the draw adds that to
every point, so the path stays around its planet. On a frame that
recomputes, the move is zero and the points are drawn exactly as
predicted.

```go
// predictPath refreshes the cached path when it is due and returns how
// far to move its points so they sit around the primary where it is now.
// A path recomputed this frame needs no move.
func (g *game) predictPath(primary ecs.Entity, horizon float64) lin.Vec3 {
	w := g.world
	settings := w.Resource[orbit.Settings]()
	var anchor orbit.Vec3
	if pb, ok := w.Get[orbit.Body](primary); ok {
		anchor = pb.Pos
	}
	anchor = anchor.Sub(settings.Origin)
	var thrust orbit.Vec3
	if t, ok := w.Get[orbit.Thrust](g.ship); ok {
		thrust = t.Accel
	}
	c := &g.path
	// The horizon follows the orbit's period, which drifts a little
	// every frame; only a real change of orbit counts.
	due := c.points == nil || c.stale || primary != c.primary || thrust != c.thrust || g.warp != c.warp ||
		math.Abs(horizon-c.horizon) > 0.05*c.horizon || g.simTime-c.at >= c.horizon/90
	if due {
		c.points = orbit.AppendPredictRelative(c.points[:0], w, g.ship, primary, horizon, 90)
		c.at, c.primary, c.anchor, c.thrust, c.warp, c.horizon, c.stale = g.simTime, primary, anchor, thrust, g.warp, horizon, false
		return lin.Vec3{}
	}
	unit := settings.Scale
	if unit == 0 {
		unit = 1
	}
	return anchor.Sub(c.anchor).Mul(unit).Lin()
}
```

## Draw: lighting a system from one star

At the scale of a whole system the star is treated as a directional
light: the direction is from the star towards the origin, and the
strength falls off with the square of the distance, normalised so it is
1 at 110 units and clamped into a usable range. That is why the outer
worlds are dimmer without any per-object work.

The second light is planet shine. The body that looms largest over the
focused point, measured as its radius over its distance, lights the
scene from its side in its own colour, so the night side of the ship is
never pure black. It is set through `Sky.Up` and `Sky.Ground`, with
`Vacuum: 1` saying there is no atmosphere to scatter anything, and the
starfield is drawn by hand instead.

```go
func (g *game) Draw(ctx *engine.Context) error {
	gr := ctx.Gfx
	w := g.world
	cam := g.camera()
	gr.SetCamera(cam)
	// Sunlight: at the scale of a system the star is a directional light
	// travelling from it towards the focused body, and its strength
	// falls off with distance so the outer worlds are dimmer.
	sun := lin.V3(0, 0, -1)
	strength := float32(1)
	if st, ok := w.Get[gfx.Transform](g.star); ok && st.Position.Len() > 1 {
		sun = st.Position.Mul(-1).Norm()
		strength = lin.Clamp(110*110/st.Position.Dot(st.Position), 0.25, 3)
	}
	// Planet-shine: the world that looms largest over the focused body
	// lights the scene from its side of the sky in its own colour, so the
	// night side of the ship is never pure black. The sky itself is a
	// vacuum: no air, and the starfield is drawn by hand below.
	sky := gfx.Sky{Up: lin.V3(0, 0, 1), Vacuum: 1}
	var loom float32
	for _, e := range g.focus {
		if e == g.ship || e == g.star {
			continue
		}
		t, ok := w.Get[gfx.Transform](e)
		l, ok2 := w.Get[look](e)
		if !ok || !ok2 || t.Position.Len() < 1e-3 {
			continue
		}
		if size := l.Radius / t.Position.Len(); size > loom {
			loom = size
			k := lin.Clamp(size*2, 0, 1) * 0.35 * strength
			sky.Up = t.Position.Mul(-1).Norm()
			sky.Ground = gfx.Color{R: l.Color.R * k, G: l.Color.G * k, B: l.Color.B * k, A: 1}
		}
	}
	gr.SetLight(gfx.Light{Direction: sun, Color: gfx.Color{R: 2.4 * strength, G: 2.2 * strength, B: 1.9 * strength, A: 1},
		Ambient: gfx.Color{R: 0.03, G: 0.03, B: 0.05, A: 1}, Sky: sky})
	settings := w.Resource[orbit.Settings]()
	origin := settings.Origin
```

## The starfield, the bodies and the orbit rings

The starfield is 400 emissive spheres at 7000 units in fixed directions.
Because the whole scene is drawn relative to the focused body, they
never move against the system, which is what a distant background should
do.

Each body is drawn no smaller than a fraction of the camera distance, so
a planet far away is still a visible dot rather than a subpixel
triangle. Nameless bodies, meaning asteroids, get a smaller floor.

An orbit ring is drawn by stepping the true anomaly right around the
ellipse and evaluating `Elements.State(mu)` at each step, then adding
the primary's current position and subtracting the origin. `Lin()`
converts the orbit package's `float64` vector to the render vector.
Moon rings are only drawn when the camera is close enough for them to
be distinguishable, and with fewer points.

```go
	// A distant starfield, fixed to the focused body so it never
	// parallaxes against the system.
	for i, d := range g.stars {
		s := 5 + float32(i%3)*2
		gr.DrawMesh(g.dot, gfx.Material{BaseColor: gfx.RGB(200, 205, 225), Emissive: 0.5 + 0.2*float32(i%4)}, lin.Translate(d.Mul(7000)).Mul(lin.Scale(lin.V3(s, s, s))))
	}
	// Bodies, drawn no smaller than a couple of pixels so a planet far
	// from the camera is still a visible dot.
	g.bodies.Each(func(e ecs.Entity, t *gfx.Transform, l *look) {
		r := max(l.Radius, g.dist*0.003)
		if l.Name == "" {
			r = max(l.Radius, g.dist*0.0012)
		}
		gr.DrawMesh(g.sphere, gfx.Material{BaseColor: l.Color, Emissive: l.Glow, Roughness: 0.9}, lin.Translate(t.Position).Mul(lin.Scale(lin.V3(r, r, r))))
	})
	// Orbit rings: each Kepler body's ellipse around where its primary
	// is now. Moons only when close enough for the rings to read.
	dotSize := lin.V3(g.dist*0.0016, g.dist*0.0016, g.dist*0.0016)
	g.orbits.Each(func(e ecs.Entity, k *orbit.Kepler, _ *orbit.Body, l *look) {
		if !l.Orbit {
			return
		}
		pb, ok := w.Get[orbit.Body](k.Primary)
		if !ok {
			return
		}
		moonOf := k.Primary != g.star
		if moonOf && g.dist > 200 {
			return
		}
		n := 128
		if moonOf {
			n = 48
		}
		mu := settings.G * pb.Mass
		el := k.Elements
		col := gfx.Color{R: l.Color.R * 0.8, G: l.Color.G * 0.8, B: l.Color.B * 0.8, A: 1}
		for i := range n {
			el.TrueAnomaly = 2 * math.Pi * float64(i) / float64(n)
			p := el.State(mu).Pos.Add(pb.Pos).Sub(origin).Lin()
			gr.DrawMesh(g.dot, gfx.Material{BaseColor: col, Emissive: 1}, lin.Translate(p).Mul(lin.Scale(dotSize)))
		}
	})
```

## The predicted path and the labels

`orbit.Around(w, e)` reports which body the ship is currently orbiting,
the elements of that orbit and the standard gravitational parameter. On
a closed orbit the prediction horizon is one and a half periods, capped;
on an escape trajectory it falls back to a fixed 60 seconds.
`orbit.PredictRelative` returns points along the future path in the
primary's frame, which is what makes the path stand still while the
ship and its primary both move. `predictPath` keeps those points, from
its own `orbit.AppendPredictRelative` call, and the draw moves them by
the offset it returns.

The labels are projected by hand: `cam.ViewProj(aspect)` gives the
matrix, the position is multiplied through it as a homogeneous point,
anything behind the camera is dropped, and the clip coordinates are
mapped into view units. Labels are hidden when the camera is far away
or the body is minor, so the screen does not fill with names.

```go
	// The ship's predicted path, in the frame of the body it orbits.
	body, _ := w.Get[orbit.Body](g.ship)
	primary, el, mu, ok := orbit.Around(w, g.ship)
	horizon := 60.0
	if ok && el.Eccentricity < 1 {
		horizon = min(1.5*el.Period(mu), 600)
	}
	pathSize := lin.V3(g.dist*0.002, g.dist*0.002, g.dist*0.002)
	shift := g.predictPath(primary, horizon)
	for _, p := range g.path.points {
		gr.DrawMesh(g.dot, gfx.Material{BaseColor: gfx.RGB(120, 220, 255), Emissive: 1.5}, lin.Translate(p.Add(shift)).Mul(lin.Scale(pathSize)))
	}
	// Labels, projected through the camera.
	vp := cam.ViewProj(float32(ctx.Width) / float32(ctx.Height))
	g.bodies.Each(func(e ecs.Entity, t *gfx.Transform, l *look) {
		if l.Name == "" || (!l.Focus && (g.dist > 60 || t.Position.Len() > 60)) || (e == g.ship && g.dist > 300) {
			return
		}
		c := vp.MulVec4(t.Position.Vec4(1))
		if c.W <= 0 {
			return
		}
		sx, sy := (c.X/c.W*0.5+0.5)*ctx.Width, (c.Y/c.W*0.5+0.5)*ctx.Height
		if sx < 0 || sx > ctx.Width || sy < 0 || sy > ctx.Height {
			return
		}
		gr.DrawText(g.font, l.Name, sx+8, sy-6, gfx.RGBA(220, 225, 235, 180))
	})
```

## The panel

The interface is rebuilt every frame inside `u.Begin(input, body)`, and
`u.Panel(title, rect, body)` nests inside it. Both take closures and
there is no exported call to end them. Widgets that hold a value take a
pointer, so `u.Slider("Thrust", &g.thrust, 0, 0.05)` writes straight
into the game's field and the next `Update` reads it.

The readout above the sliders is the orbit as elements rather than as
coordinates: which body the ship is orbiting, the semi-major axis and
eccentricity, and, on a closed orbit, the periapsis, apoapsis and
period. Those are the numbers a player steers by.

```go
	focusName := "?"
	if l, ok := w.Get[look](g.focus[g.focused]); ok {
		focusName = l.Name
	}
	u := g.ui
	u.Begin(ctx.Input, func() {
		u.Panel("Ship", ui.Rect{X: 12, Y: 12, W: 340, H: 330}, func() {
			u.Label(fmt.Sprintf("focus: %s (Tab cycles)", focusName))
			u.Label(fmt.Sprintf("speed %.3f u/s", body.Vel.Len()))
			if ok {
				name := "?"
				if l, ok := w.Get[look](primary); ok {
					name = l.Name
				}
				u.Label(fmt.Sprintf("orbiting %s: a %.1f, e %.2f", name, el.SemiMajorAxis, el.Eccentricity))
				if el.Eccentricity < 1 {
					u.Label(fmt.Sprintf("periapsis %.1f, apoapsis %.1f, period %.0f s", el.Periapsis(), el.Apoapsis(), el.Period(mu)))
				} else {
					u.Label("escape trajectory")
				}
			}
			u.Slider("Thrust", &g.thrust, 0, 0.05)
			u.Slider("Time warp", &g.warp, 1, 200)
			u.Label("W/S prograde, A/D sideways, Q/E out of plane. Drag orbits, scroll zooms. Fictional system, G = 1.")
		})
	})
	return nil
}
```

## main

```go
func main() {
	seconds := flag.Float64("seconds", 0, "exit after this many seconds")
	shot := flag.String("shot", "", "write a screenshot to this PNG")
	focus := flag.String("focus", "Ship", "body to start focused on")
	dist := flag.Float64("dist", 14, "starting camera distance")
	warp := flag.Float64("warp", 1, "starting time warp")
	flag.Parse()
	err := engine.Run(engine.Config{Title: "Bunyip space", Width: 1024, Height: 680, Resizable: true},
		&game{seconds: *seconds, shot: *shot, startFocus: *focus, startDist: float32(*dist), startWarp: float32(*warp)})
	if err != nil {
		fmt.Fprintln(os.Stderr, "space:", err)
		os.Exit(1)
	}
}
```

## What to try

- Add a planet to the `planets` table with a large eccentricity and see
  its ring against the others.
- Run with `-focus Nimbus -dist 60` to watch a moon system from the
  outside, and push the time warp slider up.
- Give the ship a fuel figure in `game`, decrement it in `Update` while
  thrusting, and show it as a label in `Draw`.
- Raise `asteroids` to 5000 and check the frame time. Massless Kepler
  bodies add no gravitational force, but computing their analytical
  positions, updating transforms and drawing them still takes work.
- Draw the predicted path in `Draw` in the star's frame instead of the
  primary's, by passing `g.star` to `PredictRelative`, and see why the
  primary's frame is the readable one.
