| name | ressort-side-scroll-world |
| description | Build the world a ressort vehicle moves through — units, parallax ground, camera framing, and speed-driven or altitude-driven zoom. Use when starting a side-view driving/flying/riding game, or when the world feels empty, the ground vanishes off screen, or speed does not read. |
The world a vehicle moves through
For the CHARACTER that moves through it — skeletons, gaits, IK, crouch and
prone, climbing, and how to judge animation without being able to watch it —
see the procedural-character-animation skill in machin-ressort-splinter-cell.
This file is about the world and the vehicles in it.
Everything here was learned building a helicopter game over a 10 km desert. It
generalises to a car, a bike or a plane, because the hard parts are units,
framing and parallax, and none of those care what the vehicle is.
Rule 1 — the world is measured in PIXELS, not metres
spr_draw rasterises with int() coordinates. A world measured in metres
truncates every sprite to whole-metre steps and shreds it.
func PXM() (v) { v = 10.0 } // world pixels to the metre
Positions in world pixels; velocities in m/s anyway, because that is what
the HUD says out loud and what the physics constants mean. Convert once, on
integration:
nh.x = nh.x + nh.vx * PXM() * dt
10 km = 100,000 world px. Floats are fine. A corollary that bites later: a
sprite drawn at less than 1:1 drops columns, so every asset needs a native
size close to its final on-screen size — you cannot downscale your way out.
Down is +y. Say it out loud once and then trust it, because it inverts
every intuition about forces: hauling a vehicle toward a load hanging below
it is a positive force. That one caught a test in this repo — the assertion
was written backwards and the code was right, which is the expensive way round
to find out.
Rule 2 — zoom is a function of the thing the player controls
The tension: a character sprite needs ~20 px/m to read, and at 20 px/m a 10 km
route is 3,400 screens. You cannot have both at one zoom, so bind zoom to the
state variable the player is already manipulating.
- Flying → altitude. Descending is zooming in.
- Driving / riding → speed. Slow is close, fast pulls back (and it doubles
as a speed cue).
Fit a hyperbola through two points you care about and check the others:
z = 960 / (alt + 40) // 0 m → 24 px/m, 20 → 16, 120 → 6, 400 → 2
Two of those were fitted and the other two came out right, which is what
happens when the curve is the right shape rather than a table of guesses.
Ease the zoom, never snap it. Driving it straight off the state makes the
whole world breathe with the controls — reads as a bug even though the number
is correct.
zs = zs + (zoom_for(alt) - zs) * clamp(2.2 * ft, 0, 1)
Rule 3 — the zoom has a SECOND constraint the design will not mention
To see the ground at all, the view must reach alt below the vehicle. The
readability curve alone put the desert eighty metres past the bottom edge at
cruise — a flight game where the world vanishes when you climb.
visible below = H · (1 + bias) / (2 · zoom) ≥ alt
so bound the zoom by it and take the smaller:
z = 960/(alt+40)
g = 460/(alt+8) // whatever the equation above gives for your window
if g < z { z = g }
Keeping the world on screen costs zoom at altitude. That is the right way round
to pay. Assert it in a test — it is invisible until someone climbs.
Rule 4 — frame the vehicle off-centre, with a floor
Lead the camera by where the vehicle is going, and sit the view below it
(for flying) or ahead of it (for driving) so the interesting half of the screen
has content in it.
tx = x + vx * PXM() * 0.55 // lead
bias = H * 0.5 * CAM_BIAS() / cam.zoom // sit low
low = (0 - y) * 0.6 // ...but never more than a
if low < bias { bias = low } // fraction of actual height
ty = y + bias
Two mistakes worth knowing in advance: the sign is easy to get backwards
(centring above the vehicle pushes the only thing on screen off the bottom),
and a bias measured in screen-heights puts the camera underground when the
vehicle is parked.
Rule 5 — speed does not read without parallax
A flat colour with a moving vehicle on it reads as hovering. Everything is
drawn in world space, so parallax is one line: shift a row with the camera
and it moves slower across the screen.
off := c.x * drift // drift 0.0 = ground truth, 0.55 = far ridge
Three layers is enough: a far ridge (0.55), mid dunes (0.28), near features
(0.0). Plus surface detail on the ground plane itself — rocks, kerbstones,
sleepers — deterministic from position so they do not swim:
rx := float(r) * 95.0 + float((r * 53) % 70)
Spacing matters more than size. Features 26 m apart in a 56 m view give two on
screen and register as nothing; tighten until several are always visible. This
matters most at the close zoom, which is exactly where precision tasks
(landing, parking, docking) happen.
Below the horizon, add strata bands so the ground has thickness rather than
being a backdrop.
Rule 6 — shapes, not rectangles
A "dune" or "hill" drawn as a rectangle reads as a wall. Build mounds from
stepped rectangles on a circular profile:
f := k / 6 // six steps, not four
ww := w * sqrt(1 - f*f*0.94)
hh := hgt * f
Four steps on a linear profile builds a ziggurat — the staircase becomes the
whole silhouette instead of a texture on it. Prefer this to DrawTriangle,
which culls by winding and is a nuisance in 2D.
Add a horizon haze gradient. A dusty world should say so in the air.
Rule 6b — towed and slung loads
Anything the vehicle drags behind it — a trailer, a sidecar, a towed glider, a
wrecking ball, a cargo net — is the same four ideas.
The link is a spring that can only PULL. That asymmetry is the character
of a towed mass: it goes slack when you back off and snatches when you pull
away, and a two-sided spring gives neither. Damp on the closing speed along
the link, so it absorbs a snatch instead of ringing:
if ext > 0 { // only past its natural length
rv := (l.vx - hvx)*ux + (l.vy - hvy)*uy
f := K*ext + C*rv
if f < 0 { f = 0 } // a rope cannot push
}
Tune K against the timestep: explicit Euler at 60 Hz wants dt·sqrt(K) well
under 1. K=180, C≈2·0.35·sqrt(K) is a stable, ropey feel.
Couple it both ways, scaled by the mass ratio, or you have a decorative
animation rather than a load:
bx = R * f * ux // R = load mass ÷ vehicle mass
by = R * f * uy
One-way coupling is much easier and removes the only interesting thing about
the job. The sub-system should return the force and let the caller apply
it — never reach into the vehicle struct — so both halves stay testable alone.
Report swing as the SINE of the angle off the rest direction, not the
angle. It is not a readout, it is the horizontal error the load will have when
you release. (It also avoids needing atan2, which is not always available.)
The attachment point rotates with the body, about the same pivot the body
does — pitch the nose down and the belly swings aft; brake hard and the hitch
lifts. A fixed offset in world space will look welded on.
Rule 7 — keep the step a pure function
func vehicle_step(v, bits, dt) (nv)
No keyboard, no globals, no rendering. That is what lets the test suite drive it
without a window, and the same property is what lets a run replay from a
recorded input stream later. Sub-systems return forces rather than reaching
into each other:
nl, bx, by := load_step(l, hx, hy, hvx, hvy, dt) // returns force
h.vx = h.vx + bx * dt // caller applies it
Both halves stay testable alone.
Rule 8 — a crowd is not one character times a hundred
Measured on a real GPU, 120 procedurally-posed figures on screen at once:
| what is drawn | frame |
|---|
| pose + solve 120 skeletons | 0.6 ms |
| full figures, one disc-run per limb | 19.1 ms |
| same figures, one line + a joint disc per limb | 4.9 ms |
| with distance LOD | 3.0 ms |
The simulation was never the bottleneck, the rasteriser was. Procedural
animation has no per-entity animation state — a soldier is (x, phase, speed, face) — so a crowd costs what its POSITIONS cost. What it does not have is a
cheap draw: a limb as a run of a dozen overlapping discs is the right renderer
for one large figure and the wrong one for a hundred small ones, and at a
figure 47 px tall the taper it gives up is under a pixel. Keep both and pick by
distance. Measure this before building on top of it, not after.
Three more crowd facts, all cheap and all things a still cannot show:
- Spread the gait phases by index. A hundred men planting the left foot on
the same frame is the most artificial thing a crowd can do, and it is the
default if everyone starts at phase zero.
- Give the street DEPTH. A per-agent
z across the road: he stands a
little higher or lower, is drawn a little larger or smaller, and is drawn in
band order (four bands beats sorting a hundred entities every frame). A crowd
sharing one y is a chorus line, and no amount of gait variation fixes it
because the fault is not in the walking.
- A cast shadow under every figure. One flattened ellipse. Without it they
hover, however well the feet are solved.
Rule 9 — separation is a CONSTRAINT, not a force
Everyone wants the same distance from the same objective, so a push-apart force
is fighting every other force at once and loses: measured spacing stayed at
0.7 m between men who are half a metre wide. Resolve it as a position
constraint after the move instead — a few relaxation passes over the pairs that
are too close, each pushing half the deficit, exactly like a ragdoll's bones —
and it holds at the gap you asked for. A constraint that is solved is worth
ten forces that are merely applied.
Only within a depth band, or the whole crowd compresses into single file.
What to assert without a window
Physics is where measurement beats looking, and these are all cheap:
- hands-off means neutral — the single most important feel property
- terminal speeds, climb/descent rates, stopping distance — and note that a
linear drag term silently sets the real top speed to
power/drag,
approached exponentially. A saloon rated 33 m/s reached 13 and felt like the
throttle needed pressing again; the fault reads to a player as an input bug,
not a physics one. Keep drag for coasting and let the stated top speed be what
limits top speed (acc *= 1 - (v/top)²), then assert the car reaches most of
it within a few seconds
- determinism: same inputs → same position, to 1e-4
- the zoom band table, and the ground-visibility bound
- boundary cases either side of a threshold (a gentle landing and a hard one)
Then render it and look, because numbers can be right while the render is
wrong. Every defect in this repo's history was found by one or the other, never
both.
Judging BEHAVIOUR, not just outcomes
When something in the world decides things — an autopilot, a doctrine, a
learned policy — "it won" does not tell you whether it did the thing you wanted.
Print the behaviour alongside the result: time spent in cover, distance kept
from the enemy, spacing to the nearest friend. Those three numbers turned "the
new controller is better" into "cover use 0% → 32%, casualties halved".
Four traps, all paid for:
- A metric that is wrong in the direction of the bug you are hunting is worse
than no metric. Mean spacing was divided by every living man rather than by
the ones who actually had a neighbour, and reported 0.8 m in a line that was
measurably 1.4 m apart.
- Do not measure a symmetric quantity when you want asymmetric behaviour.
The distance between two men is ONE number they share; if one side closes,
the range closes for both. "Side A keeps its distance" is unmeasurable that
way. What differs is what each side DOES — one holds still and kneels at
23 cm/s, the other keeps walking at 196.
- Two rules that each look right can multiply. A doctrine that says "close
the distance" also says "spend longer being shot at", so it is only balanced
against a lethality low enough that the approach survives. And a preferred
firing range LARGER than the objective's capture radius is a doctrine that
can never stand on the objective — it gets silently overridden every time,
and both sides end up fighting at two metres.
- Scores from different training runs are not comparable. Two champions
trained against different worlds both reported a positive fitness; the only
honest ranking was to put them on the same map, both ways round. The one with
the worse training number won both matches.
Bot autopilots earn their keep
A --bot flag that flies itself gives you screenshots without hands. Write it
as a controller, not a canned key sequence — canned inputs produce a pretty
picture of something nobody could actually do. Ours failed five instructive
ways before it worked: it went for the destination before picking up its cargo;
it braked at a fixed range and overshot by 170 m at 50 m/s (chase a target
speed that shrinks with distance remaining instead); it started its descent too
early, completed the task in the wrong place, then undid it; it drove at the
destination in a straight line and pressed into the first wall it could not
see round (fix: flood outward from the destination once and walk downhill —
see ressort-top-down-world); and, having braked because something was
blocked, it deadlocked, because steering needs speed and it now had none
(fix: reverse out, wheel the other way).
The last two are worth the words because both look identical from a
screenshot — a stopped vehicle — and neither is visible without a trace. A
--trace flag printing one JSON line a second (position, what tile it is on,
where it is aiming, speed) found both in one run each, after two rounds of
staring at stills got nowhere.