Forward-View Pseudo-3D Road — two shipped engines, measured¶
A forward-facing road that fans out in perspective is the single most-asked-for effect on this machine, and there is more than one way to do it. Two commercially released cartridges solve it differently, and both were read out of a running emulator rather than guessed: one is a driving game, the other a rail-vehicle simulation. Neither is named here — what follows is the mechanism, which is what transfers.
Everything below was measured against the running game (per-scanline register logs, frame-by-frame table dumps) and then re-verified by porting it. Where a figure comes from our own port rather than from a cartridge, it says so.
Prerequisite: Effects and Raster §1.4 for the per-scanline scroll mechanism itself, §1.4b for how a hill works, §1.4c for how to debug one.
1. The principle, in one sentence¶
One background plane is sheared: every scanline gets its own horizontal scroll value, so a plane that holds a straight strip of tarmac bends into a perspective road. The world image never changes — verified on one cartridge as zero bytes touched over 600 frames. What changes is a table of registers, once per frame.
The two engines differ in what fills that table.
2. Engine A — sample a profile at a quadratic index¶
The cheaper of the two. It does not compute a curve; it samples a signed profile table at a quadratic index.
index(k) = (k * k) / DIV k = 1..BANDS
scrollX[LINES - k] = profile[base + index(k)] - profile[base]
base = profile_pointer + corner_offset
Three things make this work, and all three are easy to get wrong.
The index is quadratic, and the window is small. On the cartridge, index(k) = k²/64
for 48 bands, so only 37 profile entries are ever read. A whole "segment type" therefore
fits in 37 signed bytes. Verified 597 times out of 600 against the table the game itself
produced, live.
The law generalises by keeping that 37-entry window:
48 bands → 64 (the cartridge). 56 bands → 87 (our port).
The table fills DOWNWARD. Band k lands on line LINES − k, so k grows toward the
top of the screen: zero deviation under the player, maximum at the horizon. That is what
puts the vanishing point where the player is, and reversing it puts it on the bumper.
Advance and steering are the SAME control. A single offset slides the sampling base along the profile. While the window sits in a flat region the road is straight; it curves as soon as the window reaches a ramp. There is no separate "corner engine" — the profile IS the track, and a long profile with long ramps and long flats is a real circuit.
One plane, not two. Measured over 600 frames of driving: the cartridge never writes the per-line table of the second background plane. There is no line-scroll parallax. The other plane is a fixed layer (HUD, or a ground plane).
⚠️ Align the bands to the road you actually drew. If the bands overrun the painted tarmac, the part of the fan that falls in the sky is wasted. Our port uses horizon 96 with 56 bands, which is exactly lines 96-151 of the art.
3. Engine B — a staircase of slices, with a wider per-line table¶
The other cartridge spends more per line and buys hills with it.
Eight bytes per scanline, not one scroll value: SCY, SCX, and three 15-bit
colours. The tarmac that streams past is a background palette entry rewritten every line;
the haze bands come from the same table.
The road is a staircase. 48 slices of two map rows each, walked from the bumper line upward. Each slice is granted 0, 1 or 2 screen lines by adding a per-gradient rate twice into an 8-bit accumulator and counting the carries. The gradient model is the ratio described in Effects and Raster §1.4b.
Curvature is linear ramps that sum to a parabola. Each slice adds a 16-bit word from the
segment's curvature profile into an accumulator; SCX is the high byte. The 21 profiles are
straight lines:
so the curvature increment of a slice is proportional to its distance. Accumulating from the bumper upward makes the total grow as i² — the parabola of a corner, with no multiply at runtime.
🔑 The ramp index is the GLOBAL slice index, not an index local to the segment. A distant corner is therefore sampled in the strong part of its ramp automatically, and it "arrives" on its own as the slices come closer. Get this wrong and corners snap into existence instead of approaching.
4. The track format that makes both cheap: four bytes per segment¶
| byte | bits | field |
|---|---|---|
| 0 | 0-4 + sign in 5 | gradient, sign-magnitude |
| 0 | 6-7 | high bits of a roadside-scenery code |
| 1 | 0-4 + sign in 5 | curvature, sign-magnitude |
| 1 | 6-7 | low bits of that code (one value = finish line) |
| 2 | 0-5 | parameter 1 |
| 3 | 0-5 | parameter 2 |
| 2, 3 | 6-7 | event code (the centred corner sign; one value clears it) |
Two details worth stealing:
⚠️ Sign-magnitude, not two's complement. Bit 5 is the sign, AND 0x1F the value — so
"+0" and "−0" both exist and the decoder has to mean it. A classic sign-extend reads
garbage.
🔑 The reversed circuit is FREE. A mirror flag makes the loader XOR 0x20 the curvature
field (flipping its sign bit) and take 0x3C − p1 for the parameter. One dataset, two
racing directions.
And the author writes ramps, never steps. On a measured track the gradient takes only
eleven distinct values and always arrives as 0, +2, +5, +10, …, +10, +5, +2, 0; corners
likewise 0, 1, 2, 4, 4, 4, 2, 1, 0. A hill or a corner is never dropped in as a single
segment — the ramp is the shape.
5. Where the gameplay comes from: the same bytes¶
The most transferable idea in either engine. There is no separate physics data. The bytes that draw the road are the bytes that drive the car.
Gradient → speed, and it is deliberately asymmetric. Reading the gradient of the nearest
segment: climbing decelerates by 1.5 × gradient, descending accelerates by 8 × |gradient|,
both added into an 8-bit accumulator that only acts on carry (so the gain is B/256 speed
units per frame).
A descent gives back 5.3 times what a climb costs, at equal gradient. At −10: +0.31 units per frame. At +10: −0.06. That ratio is an arcade choice and it is what makes a descent an event. Measured against the running game: over the 200 frames of a descent ramping −2 to −10, speed rose +0.215/frame, exactly what the gradient ramp predicts.
The crest jump is a subtraction. The car takes off when the gradient drops from one
segment to the next, and the airtime is speed × drop, gated by a minimum. No "bump" test,
no geometry: with a writing scale of 0, ±2, ±5, ±10, ±15, ±20 you need a drop of at least two
steps (+10 → 0) and some speed.
A corner pushes from a distance that depends on speed. The push is read from the
curvature of segment n, where n = min(speed / 16, 7). Faster means the corner starts
pushing from further away — the same table, sampled deeper.
6. Traps, all of them paid for¶
- ⛔ Quantising the gradient without a dead band flickers. See Effects and Raster §1.4b.
- ⛔ A descent gives nothing if the top row of the sky board is not a uniform colour — repeating it reads as a hard-edged stripe, which forces you to clamp descents and costs you half the relief. Fix the art before tuning the numbers.
- ⚠️ A short hill is not a weak hill. The intuition that a segment shorter than the look-ahead only unfolds a fraction of the relief is wrong: measured, a 400-unit bump reaches the same full deflection as an 1800-unit plateau. It just takes ~300 units to get there.
- ⚠️ A hill sells itself by how much road it removes, so it needs road to take. The same numbers read as timid on a 32-slice strip and strong on a 48-slice one. If your relief looks weak, check the strip before the constants.
- ⚠️ Clamp the table index. Gradient tables are indexed
centre + gradient; a track byte outside the intended range walks straight off the end.
7. Corners that ARRIVE — per-scanline look-ahead¶
Everything above describes the shape of the shear. This section is about its content: where the curvatures you put into it come from. It is the fix for the most common complaint about a homemade forward-view racer — "the corner appears all at once, you never see it coming" — and it was built and measured on a complete game.
7.1 Why one curvature number cannot work¶
A shear built from one number — the curvature of the segment the car is on — tilts the whole ribbon the same way, horizon to bumper. A corner does not approach, it switches on. Smoothing that number over time makes it worse to describe than to watch: a straight road that slowly becomes a curved one, in one block, at the exact moment you enter it.
⚠️ No smoothing value fixes this. Temporal smoothing is a substitute for geometry, and what is missing is geometric: nothing in the drawing knows what lies beyond the car.
The two corrections are independent and you need both. A correct anchoring (two terms anchored at opposite ends, see §3) with a single curvature gives a pretty corner that switches on; a correct look-ahead with a wrong anchoring gives a road that slides under the wheels.
7.2 Each scanline looks at ITS distance¶
Ground at distance d projects toward the horizon as 1/d — the same law that places the
sprites (§10.1). Inverted, the scanline of depth p (0 at the horizon, 255 at the bumper)
looks at:
LOOK_NEAR is the knob: it sets how much road is on screen, therefore how long a corner
takes to come down the screen. Measured at 8 profile units per frame, full throttle:
LOOK_NEAR |
top line looks at | first pixel of corner | shear at mid-approach |
|---|---|---|---|
| 30 | 926 units | 33 frames before | −16 px |
| 60 | 1852, clamped to 1600 | 43 frames before | −27 px |
What changes most is not when the corner appears (ten frames) but how fast it deepens once it has appeared. At 30 it points and stays discreet until the entry; at 60 it digs in while you arrive, and that is what reads as a corner coming.
⚠️ Anchor on DEPTH, not on scanline number. A road board that samples its image
unevenly (88 image rows squeezed into a 48-line band, say) crushes the whole approach into
the bottom of the screen if distances are built from the line index k.
7.3 Sample the profile by a WALK, never by searches¶
Distances only grow going up the screen, so the segment cursor never moves backward: start from the current segment and walk forward.
cursor = seg_index; into = seg_pos; walked = 0;
for (k = lines - 1; ; --k) { /* bumper -> horizon */
want = ahead[k] - walked;
while (into + want >= span[cursor]) {
want -= span[cursor] - into;
cursor = (cursor + 1) % TRACK_LEN; /* the walk WRAPS, see below */
into = 0;
}
into += want;
walked = ahead[k];
bend[k] = blended_curvature(cursor, into); /* §7.5 */
if (k == 0) break;
}
~48 comparisons per frame, not one division. The walk must wrap past the end of the track: the top of the band sees beyond the finish line, and a profile that stopped there would draw the last few hundred units of every lap perfectly straight.
⚠️ The band samples very unevenly. In 1/d, the top lines cover hundreds of units and
the bottom ones a few. On a 32-line band with LOOK_NEAR 60:
| line | 0 | 1 | 2 | 3 | 8 | 16 | 31 |
|---|---|---|---|---|---|---|---|
| looks at | 1600 (clamped) | 1600 (clamped) | 896 | 577 | 175 | 56 | 0 |
A 300-unit segment can fall between two sample lines and be seen by neither. The blend of §7.5 makes that harmless — the profile is continuous, so something crossing the gap fades in instead of blinking. Without a blend, a sampled look-ahead makes distant corners flicker — a defect you naturally blame on the display when it lives in the profile read.
7.4 Double integration, anchored at the BUMPER¶
Curvature is a change of direction: the heading reached at a distance is the sum of the curvatures up to it, and the lateral offset of the tarmac is the sum of that heading.
slope = 0; acc = 0; /* 1/256 px per line, 1/256 px */
for (k = lines - 1; ; --k) {
slope += signed((|bend[k]| * cm + 128) >> 8); /* round the MAGNITUDE, re-sign */
acc += slope;
dx[k] = acc >> 8;
if (k == 0) break;
}
Two properties make the change safe:
- A road of CONSTANT curvature comes out exactly as before. The double sum is a quadratic — the ramp the engine used to make by squaring. What is new only shows on a MIXED profile: straight near, curved far.
- Summing from the bumper puts the zero under the car, where the car really is. Anchoring at the horizon makes the road slide under the wheels every time something changes a second ahead.
The gain cm brings a constant curvature B back to dx(horizon) = B:
⚠️ 512 * 256 is 131072 and a cc900 int is SIXTEEN bits. Written as is, the
constant wraps to zero, the gain is zero and every corner in the game is drawn straight
— with nothing that looks like an overflow, since a table full of zeros looks like nothing.
Compute it in two steps (half, rounded, then times four) or in u32. It also depends on the
band height: recompute it when a track with a different band loads.
⚠️ Round the gain multiplication. With cm = 124 a 6-unit kink truncates to 2 and comes
out a third too flat. +128 before >> 8, on the magnitude, then re-sign — otherwise the
rounding is not symmetric.
The 16-bit budget — redo it before changing anything:
| quantity | worst case | s16 ceiling |
|---|---|---|
mag * cm |
62 × 124 = 7 688 | 32 767 |
slope (accumulated curvature) |
~30 × 48 = 1 440 | 32 767 |
acc (accumulated offset, 1/256 px) |
256 × 62 = 15 872 | 32 767 |
There is a factor of two of headroom, not ten. Doubling the curvature range or the band
height overflows acc silently — and an overflow there does not crash, it flips the road
in the middle of a corner.
⚠️ Check the SIGN on screen, not in your head. With the display showing
plane_pixel − scroll, a positive dx accumulated at the horizon slides the road LEFT; the
drift must agree (a curvature that bends the road one way throws the car toward the
outside). Control: enter a right-hand corner and check the car is pushed left. One port
wrote this sentence backwards, believed it, and its rival took every hairpin leaning the
wrong way.
7.5 Blend in DISTANCE: a corner is not a point¶
A segment table makes a corner start at a POINT. Two corners that touch meet as a step (−62 to +62): drawn, the road changes shape in one frame; driven, the car is thrown the other way in one frame. A real road enters a corner in a spiral — and a run of alternating hairpins with nothing in between is exactly where this gets reported first.
Hence a blend in distance, centred on each joint:
/* the nearer half decides, otherwise a segment shorter than the blend
would be pulled by both ends at once */
if (rem <= into && rem < HALF) { other = next; w = HALF - rem; }
else if (into < HALF) { other = previous; w = HALF - into; }
else return bend[cursor];
return bend[cursor] + (((bend[other] - bend[cursor]) * w) >> 8);
BEND_BLEND = 256 units (HALF = 128) — 256 so the blend divides by a shift. w rises to
128, so the blend reaches exactly half on the joint, where the next segment's blend picks
it up at the same value.
In distance, never in frames, and that is the point: the image and the physics read the same function, one at the scanline's distance, the other at zero. They cannot disagree. A blend counted in frames re-creates a gap between what is drawn and what pushes the car, and lasts the same at 20 km/h as at 200 — wrong in both cases.
7.6 The physics reads the same thing, at distance zero¶
The term that drifts the car becomes blended_curvature(current segment, position). What
remains of temporal smoothing is only a damper: a blend sweeps at most ~124 units over
256 of road, about 4 per frame at full throttle, so a cap of 6 per frame is never reached on
a normal joint — it only catches what a blend cannot round, a segment shorter than the
blend itself.
Why this matters: with the smoothing at 2 per frame (from the days when that variable also drew the road) and hairpins coming in pairs, the car spent four fifths of the second corner pushed by a number still on its way to the one displayed. Reported as "at the car, entry and exit feel wrong".
7.7 What rides on the road follows for free — and must not cache¶
Everything placed on the road (roadside posts, the rival, props) takes its X from
centre_at(line) = art_centre − shear[line]. As soon as the table is curved, an object at
mid-screen stands on the tarmac as drawn, not on an imaginary straight line.
⚠️ Never cache centre_at() between frames, and never recompute an object position from
"the current corner's" curvature. Both give an object floating beside its road during
transitions — visible only in chains of corners, which is where nobody reads the code.
The parallax follows the HEADING, not the far curvature. The bands above the road (sky,
ridges, tree line) integrate current_bend × speed: they move by what the nose of the car
actually swept. Wired to the far curvature, the scenery would start turning before the car
does — which reads as scenery sliding.
7.8 Per-line clamps apply to the SUM¶
Each line has a minimum and maximum dx, computed from the art when the track loads: the
road is a triangle, its far end is a few pixels wide, and past some offset the 256-px ring
brings the opposite side of the image in from the edge. Clamp the sum of the two terms
(lateral + curve), not each one.
Consequence to know before touching LOOK_NEAR: the further you look, the more the double
integration accumulates, and a distant hairpin plus a car already at the edge can
saturate. The symptom is clean — the horizon freezes at one value — and it comes from the
clamp, not from the maths. Read dx at the horizon on an established corner: if it sticks
to the clamp, that is the cause.
7.9 The order inside the frame is not negotiable¶
drive() physics reads the current (blended) curvature
track_advance() advance along the profile, recompute the current curvature
build_road_ahead() sample the profile AT the line distances
road_frame_ahead() integrate and write the shear table
rival / scenery / props read centre_at()
⚠️ Sampling before advancing draws the road one frame late: invisible when stopped, and at speed a permanent eight-unit gap between what you see and what you drive. ⚠️ Placing sprites before the shear is written puts them on last frame's road — again only visible in chains of corners.
7.10 What it changes in the GAMEPLAY¶
The blend is not cosmetic. Curvature now starts 128 units before the joint, so the
corner bites earlier: a flat-out driver who turned in at the last moment now runs wide.
With the same crude autopilot, one descent went from 3 809 to 7 629 frames — half of that is
the bot's clumsiness, but the direction is right: the track got harder. BEND_BLEND is a
difficulty setting as much as a rendering one — shorter gives back dry joints, longer
softens chains and asks you to anticipate.
Corner warning signs became unnecessary — they existed because the shear could only show a corner already begun. The road now announces itself. ⛔ What must stay: the read of the next corner one segment ahead (≈900 profile units). It is the only number that knows what is coming, and it is what tells the AI rival to lift before a bend; without it the rival takes every corner flat out.
7.11 Dead ends, all tried¶
| Tempting move | What it gives |
|---|---|
| Smooth the curvature in TIME | A corner that still switches on, only softer. Smoothing cannot show what lies beyond the car. |
| Keep temporal smoothing on top of the distance blend | Pure latency between image and car. The blend already limits the sweep rate. |
| Sample the profile without a blend | Two touching hairpins meet as a step: the road changes shape in one frame and the car is thrown the other way. |
| Anchor the integration at the horizon | The road slides under the wheels whenever something changes a second ahead. |
| Build distances from the scanline number | Correct only while a scanline is a fixed slice of road — wrong as soon as a board samples its image unevenly. |
Compute the gain cm once for all |
It depends on the band height, which changes with the track. Recompute on load. |
| Trust "no error" from the build | The toolchain accepted a gain of zero. Check the BYTES and the screen. |
7.12 How to verify¶
Read the per-scanline register log of the emulator (the plane-2 X scroll, 0x8034) — see
Effects and Raster §1.4c. dx = (log[line] − base) & 255, signed.
⚠️ First check WHERE the band is in the log. A bias inherited from another harness
(logging 20 lines late) once made the HUD band — flat by construction — look like the road,
and nearly concluded "the ramp does not descend". The identification that needs no prior
knowledge: dump the 152 entries and look at the RANGES — constant values (sky, ridges,
trees), then N values climbing one by one (the road), then a constant (the HUD). If the
climbing part is not there, do not read further. And the road band may start one line
above your road_top variable: a one-line offset reads a value that exists and is wrong.
What to expect, frame after frame, on a whole descent: |dx| non-zero at the horizon while
the car's own curvature is still 0 (the corner forms far away); frame-to-frame change ≤ ~4;
on an established corner, a ramp from −62 at the top of the band to 0 at the bumper.
8. Hills — the complete staircase recipe (verified against the cartridge)¶
§3 described Engine B's hills in one paragraph. This is the full recipe, with constants, the algorithm, the tables, and a bench that tells you whether your port is right. Every figure was obtained by a controlled experiment: breakpoint just before the road builder runs, overwrite the eight segment gradients in RAM, read what the engine does with them — which sweeps the whole range without waiting for the track to climb and without patching the ROM.
8.1 The model in six equations¶
Reference geometry: 48 slices, always; one slice = 2 rows of the road board (a
96-row board); a 103-line band served by the raster; 56 rows of scenery above. For a
gradient g, clamped to ±20:
(1) rate = 128*(1+g/20) if g >= 0 41-entry table, 128 = flat
= 128/(1-g/20) if g <= 0 (fits every entry to 1 unit)
(2) road_lines = SLICES * rate / 128 24 .. 96, 48 when flat
(3) horizon = BOTTOM + 1 - road_lines the bottom is NAILED
(4) scenery_lines = BAND - road_lines
(5) clip = max(0, SLICES - road_lines) sky lines repeated
(6) scenery_shift = -clip the scenery comes DOWN by as much
And the one reading law that matters:
A slice ALWAYS advances 2 rows of the board. What the gradient changes is how many screen lines (0, 1 or 2) that slice occupies.
On screen that gives a vertical step of 1, 2 or 4 rows per line — never 3 (a slice on two lines = 1 row each; on one line = 2 rows; a skipped slice makes the next one swallow 4).
8.2 The tables¶
/* index = 20 + gradient, gradient in [-20, +20]. 128 = flat. */
static const u8 hill_rate[41] = {
64, 65, 67, 69, 71, 73, 75, 77, 80, 82, 85, 88, 91, 94, 98,102,
106,111,116,121,128,134,140,147, 153,160,166,172,179,185,192,198,
204,211,217,224,230,236,243,249, 255
};
Not an exponential (an exponential fit is off by 11): a reciprocal pair. With
k = 1 + |g|/20, climbing, the road takes k times more lines; descending, each line
swallows k times more rows. The ends are ×0.5 and ×2.
Scrolling is a re-partition, not a scroll. The 48 slices are shared between the eight visible segments by a small table indexed by a sub-phase (the top three bits of the sub-position): every row sums to 48, the nearest segment melts from 12 slices to 1, and a new one enters at the far end. The road never moves; its slices change owner.
Curvature profiles are linear ramps — profile[k][i] = round(0.75 * k * i) — so
compute them ((3*k*i) >> 2) instead of storing them. i is the GLOBAL slice index, not
local to the segment: that is what makes a distant corner weigh more and "arrive" by itself.
8.3 The builder, in C89¶
It fills one entry per scanline; this table is what the MicroDMA pushes.
static u8 line_row[BAND]; /* board row to show on this line */
static s16 line_x[BAND]; /* shear */
void road_build(const seg_t seg[8], u8 phase, s16 lateral, s16 heading)
{
u8 i = 0, acc = 0, line = BOTTOM, row = BOARD_BOTTOM, n, k, c, rate, a, len;
s16 bend = 0;
/* acc is reset HERE, once per frame -- that keeps the dither identical
from one frame to the next (pitfall 6) */
for (n = 0; n < 8; n++) { /* the road */
len = seg_len[phase][n];
if (len == 0) break;
rate = hill_rate[20 + CLAMP(seg[n].grade, -20, 20)];
for (k = 0; k < len; k++) {
/* 1. how many screen LINES this slice takes: 0, 1 or 2.
The rate is added TWICE; the carries count. */
c = 0;
a = acc + rate; if (a < acc) c++; acc = a;
a = acc + rate; if (a < acc) c++; acc = a;
/* 2. curvature accumulates EVEN IF the slice takes no line */
bend += curve_ramp(seg[n].curve, i);
/* 3. write. The two lines of a stretched slice show
CONSECUTIVE rows -- not the same row twice. */
if (c >= 1) { line_row[line] = row; line_x[line] = (bend >> 8) - lateral; line--; }
if (c == 2) { line_row[line] = row - 1; line_x[line] = (bend >> 8) - lateral; line--; }
/* 4. the slice ALWAYS advances two rows, even when skipped */
row -= 2;
i++;
}
}
if (line >= TOP + (BAND - TOP - SLICES)) {
/* CASE A -- short road: the scenery comes DOWN and sticks under the
horizon 1:1, then its top repeats its highest row. */
u8 sky = line_row[line + 1] - 1;
while (line >= TOP) {
line_row[line] = (sky > SKY_TOP) ? sky : SKY_TOP; /* clip */
line_x[line] = heading - lateral; /* scenery follows HEADING */
if (sky > SKY_TOP) sky--;
line--;
}
} else {
/* CASE B -- long road: the scenery does NOT move, the road eats it
from below. */
while (line >= TOP) {
line_row[line] = line;
line_x[line] = heading - lateral;
line--;
}
}
}
Five points are non-negotiable, each paid for by a visible defect:
- A two-line slice shows
rowthenrow − 1. The reference engine gets there by copying the offset, not the row: since the displayed row isline + SCY, the same offset on two lines gives two different rows. Copying the row makes a staircase — and this is precisely where the board's reserve of vertical resolution is spent: climbing shows the intermediate rows that flat road throws away. row -= 2happens in every case, even for a skipped slice. That creates the step of 4.bendaccumulates even whenc == 0. A skipped slice still exists for the corner; forgetting it "loses" curvature over crests.accis not reset between segments — only at the start of the frame. The dither runs across segment borders, otherwise the joints show.- Scenery follows the HEADING, the road follows the CURVATURE — two different shears in the same frame.
8.4 The scenery, exactly¶
| condition | what the scenery does | |
|---|---|---|
| case A | road_lines ≤ 47 |
55 rigid lines (1:1) stuck under the horizon, then 48 − road_lines lines repeating the highest sky row. The scenery has come down by 48 − road_lines px. |
| case B | road_lines ≥ 48 |
103 − road_lines lines at their natural place (zero offset). The scenery does not move; the road eats it from below. |
Verified over the whole range: clip = 48 − road_lines to the pixel, and
55 + clip + road_lines = 103 in every case. Two consequences to accept, because they are
the rendering: climbing, the scenery/road seam hides road_lines − 45 rows (tree line,
foot of the mountains) — that is what a hill does; and the row repeated by the clip must be
plain sky — any detail in the board's first row gets combed over 18 lines.
8.5 The shear is computed per SLICE, not per line¶
Same curvature forced under three gradients:
| gradient | road lines | horizon → bumper shear | amplitude |
|---|---|---|---|
| +10 | 71 | 18 → 44 | 26 px |
| 0 | 47 | 18 → 44 | 26 px |
| −10 | 30 | 19 → 44 | 25 px |
A corner has the same total amplitude whatever the hill. There are always 48 slices, curvature accumulates per slice, and the hill only redistributes the sum over more or fewer lines. Computed per screen line, a climb exaggerates the corner and a crest flattens it — wrong both ways and impossible to tune.
8.6 Twelve pitfalls, all hit¶
- Clamp the gradient to ±20. The index is
20 + g; outside, the engine reads the neighbouring tables. Measured: +31 → the road drops to 5 lines with 14-row steps. It does not crash, it becomes nonsense. - An end-of-line interrupt paints the NEXT line. In HBlank mode the entry for line
LservesL+1. The MicroDMA has its own offset — measure it, do not assume it. - Never scale the scenery. It is 1:1, always. All the perspective is in the road.
- Anchor at the bumper, never at the horizon. The horizon is a RESULT.
- A step of 3 does not exist. If you get 3-row steps, the accumulator is wrong: the rate is added twice per slice and the carry is 0, 1 or 2 lines.
- Reset the accumulator every frame, and only there. Measured: 117 frames out of 120 give exactly the same pattern at constant gradient. A free-running accumulator shimmers.
- A two-line slice shows two CONSECUTIVE rows, not the same one twice.
- Accumulate curvature on skipped slices too.
- The board must hold two rows per slice. THE lock: flat, you drop one row out of two, and that reserve is what lets you stretch up to ×2 without duplicating a row. A board shown 1:1 when flat can only staircase.
- The first row of the scenery board must be uniform (§8.4).
- The player's car is part of the hill. It does not sit on the bumper line but a few
slices up the band, so the re-sampling moves it: measured on the reference OAM, +8 px
on a full climb, −5 on a full descent, one pixel at a time. There is no curve to embed:
the car stands on a slice of road, like a post —
bumper − band_lines/6(a sixth: 47 lines of horizon travel for 8 px of car): whereline_shift()is the function that already tells where an image line landed — the one posts use. Forget it and the car stays nailed while the road tilts under it. - The lift moves the IMAGE, never the wheel line. The car takes its X from the road axis read at the line of the bottom of its box, and that axis shears with the corner. Raise the box and the car drifts sideways as it climbs — reported on the very first try. Pass the wheel line and the image top separately to the draw.
8.7 The acceptance bench¶
The reference engine's transfer function, measured by forcing the gradient. A correct port reproduces these columns, scaled to its own band.
| grad. | rate | road lines | horizon | rows/line | scenery shift | clip | seam |
|---|---|---|---|---|---|---|---|
| −20 | 64 | 23 | 88 | 4.00 | −25 | 25 | 3 |
| −12 | 84 | 29 | 82 | 3.14 | −19 | 19 | 3 |
| −4 | 111 | 38 | 73 | 2.43 | −10 | 10 | 3 |
| 0 | 128 | 47 | 64 | 2.00 | 0 | 0 | 2 |
| +4 | 147 | 56 | 55 | 1.67 | 0 | 0 | 11 |
| +12 | 194 | 75 | 36 | 1.26 | 0 | 0 | 30 |
| +20 | 255 | 94 | 17 | 1.00 | 0 | 0 | 49 |
Invariants: road_lines ≈ 48·rate/128 (± the seam); the per-line step is only {1,2}
climbing, {2,4} descending, {2} exactly when flat; road_lines + scenery_lines = 103.
Expected dither patterns, horizon → bumper:
gradient +6 : 1 1 2 2 2 1 1 2 2 1 1 2 2 1 1 2 2 2 1 1 2 2 1 1 2 2 2 1 ...
gradient -6 : 4 2 2 4 2 2 4 2 2 4 2 2 2 4 2 2 4 2 2 4 2 2 4 2 2 2 4 2 ...
The recipe above, run as a script against the measured sweep: 0 differences over 21 gradients, maximum error on the mean step 0.03 row.
8.8 What changes on NGPC¶
| reference (other handheld) | NGPC / K2GE | |
|---|---|---|
| per-line table | 1 byte SCY + 1 byte SCX |
2 words: X and Y of SCR2 (0x8034/0x8035) — the table doubles |
| push | HBlank interrupt, ~109 cycles/line | MicroDMA, 0 CPU cycles per line |
| per-line palette | 3 colours rewritten each line (haze + bands) | a second DMA channel can push one palette word per line; three is out of budget — paint the haze into the board |
| board | 96 rows for 48 flat lines | same rule: 2 × flat_lines rows |
| cadence | logic at ~24 Hz, raster at 60 | rebuild the table at the loop's rate, once per loop turn, not per frame |
8.9 Three defects found by looking, not by computing¶
- The lines a descent gives back to the scenery must repeat the scenery's LAST row, not get the scenery's scroll value: the scroll is an OFFSET, so line 72 with offset zero shows row 72 of the plane — which is where the road image starts. The road appeared twice.
- The first road line (the vanishing point) belongs to the scenery. Nine pixels wide, its shear is zero by construction; squeezed between a scenery that follows the heading and a road that follows the corner, it followed neither — one line jumping on its own.
- Roadside posts must use the SAME line mapping as the road loop. An approximation of "where did this image line land" derived from the gradient was exact for the previous mechanism and described nothing after it. Derive it from the same mapping and it is exact by construction.
And one about amplitude: a hill sells itself by the road it removes. The mechanism can be right and the effect weak because the band is short (a 32-line band moves its top 5 px where a 76-line band moves 39). Check the board's height before the constants.
9. "The top of the road flickers" — one symptom, five causes¶
One symptom reported from the console — "the top of the road flickers", "the horizon line jumps" — had five independent causes, found one after the other over several days. Each was announced as THE cause; four times the answer was "it still does it". The main lesson: on this symptom, a cause found is not the symptom gone.
| # | cause | where | fix |
|---|---|---|---|
| 1 | per-scanline table in a SINGLE buffer, read by the DMA while the loop rewrites it | everywhere | two buffers, swapped at VBlank |
| 2 | the top of the band steps back one line when one slice changes gradient by one notch | climbs | one-line hysteresis |
| 3 | band colour chosen per SLICE while the slice→line mapping changes every frame | climbs | fade the bands on the lines the climb added |
| 4 | gradient notch derived by a plain division | climbs | half-notch dead band (Effects and Raster §1.4b) |
| 5 | horizontal pan of the panorama = two terms rounded separately | turning | the panorama follows the heading only |
9.1 Cause 1 — the table is torn¶
If the DMA is re-armed from a VBlank hook, the channel reads the table during the whole image, one entry per scanned line — while the game loop rewrites that very table (and it has time to: the loop takes more than one frame). Lines already scanned keep last frame's values, the next ones get the new values: the table is torn, and the seam wanders with the loop's duration. It falls, by construction, near the TOP of the screen (scanned first), and it is worse on climbs, where the frame costs more. Measured as pixels changing between two game frames on lines 31..38: 305–480 before, 28–64 after.
Fix: double the table (and any per-line palette table). The loop writes one, the channel
reads the other, and the re-arm swaps them — the only moment the transfer is stopped and
nobody writes. Swapping is two pointer assignments. A ready() call after the draw says a
buffer is complete: if a frame overran and no draw finished, there is no swap and the
channel shows the last COMPLETE image — exactly what you want instead of a torn one. Cost:
+608 bytes of RAM (two tables of 152 words), no measurable time.
⛔ The trap: whatever is written ONCE. Two buffers break everything not rewritten every frame: a HUD band written when the track loads, the "flat road" reset used by the menus — both must now be written into BOTH buffers. And a cache that skips rewriting a block when its inputs did not change is wrong with one key for two buffers: the frame after a change would serve a buffer whose block is one frame older. Give the cache one key per buffer (a cache invalidated on every swap is dead weight: it cost 0.3 VBlank per loop turn here).
⛔ And a cached block must not skip what the road wrote UNDER it. The scenery block was written after the road walk and covered the top 3 road lines ("tip hidden"). Skipping the block on a cache hit left those lines as ROAD, so the border jumped 3 lines up then back: 97 jumps of exactly 3 lines — the tip size. Fix: the loop always starts at the tip; only the part above it depends on the cache.
9.2 Cause 2 — the band top steps back one line¶
At a constant +11 gradient, the sum of the slice rates went down then up by 19 — one notch on ONE slice (0.17 % of the sum) — because the sampled profile is not monotonic while a sample point crosses a segment blend. Enough to lose one line of road. Fix: hysteresis. The band top is a RESULT, so do not pin it; forbid it only to step back by a single line without insisting for 16 frames. Growing is immediate; a recession of two lines or more passes immediately (a real end of climb). 16 is measured: at 6 a request that insisted 7 frames still got through. Bascules over 140 climbing frames: 4 → 1 (hold 6) → 0 (hold 16).
9.3 Cause 3 — band colours chosen per slice¶
The tarmac bands (a palette entry pushed one per line by a second DMA channel) are chosen per slice and copied to the line(s) that slice took. Flat, a slice always takes the same line: stable. On a climb it takes 0, 1 or 2 lines depending on the carry, and that repartition changes every frame, so a given line gets a different slice's colour each frame. Half of all changing pixels were swaps between the two ends of the band ramp. The metric that finally decided it needs no threshold: what share of the road's width, on a given line, changes from one game frame to the next — flat 0–4.6 %, climbing 23–58 %.
Indexing bands by distance instead was tried and rejected (far bands go thinner than a pixel
and shimmer for another reason). Fix: fade the bands on exactly the lines the climb added
(resting_road_top − current_top, capped at 24): flat, nothing is faded. After: 3.8–11.3 %,
no shade swap.
9.4 Cause 5 — the panorama pan, two terms rounded separately¶
The panorama's X was −heading × 2 + lateral / 4, both terms already rounded to the pixel by
the caller. In a corner they move in OPPOSITE directions, each crossing its own rounding at
its own moment, so the sum advanced, retreated, advanced (6 round-trips, three of them of 3
px). Rounding once (passing sixteenths) did not change a single number. The real fix:
the panorama follows the heading only, not the car's lateral position — which is also the
correct physics: a distant background does not move because the car changes lane, it moves
because the car changes direction.
⛔ Found on the way: for a lateral value decreasing steadily (−1008, −1023, −1038) the computed offset came out 30, 33, 32 — non-monotonic on a monotonic input. Do not trust an expression mixing shifts and signs on this toolchain: measure it.
9.5 How to measure it (this is what cost the most)¶
- Do not measure the image. One image per game frame hides what moves between VBlanks; "first line with tarmac" catches roadside posts (use three CONSECUTIVE pixels: hundreds of false jumps drop to 29); "image returns identical" matches by coincidence on repetitive backgrounds (building windows); sprites change mid-scan and fake a tear.
- Measure the TABLE the transfer pushes: the horizon is the first line whose plane-2 vertical offset is not zero. No colour, no sprite, no threshold.
- The player's save state carries the scene. The dead band of cause 4 changed nothing on the automated probe (the range it could reach did not contain the case) and removed the defect on the console. A save state only works with the ROM that produced it — any added variable moves everything — so rebuild the exact ROM first.
- Check which track you are on by reading its length before concluding: a menu walk that "presses right N times" silently lands on another track.
10. Objects on the road¶
10.1 Projection is 1/d, never linear¶
Linear placement in distance looks exactly like scenery falling from the sky. Precompute
both as tables (no division per object). A single division, without rounding the distance
first, keeps sizes continuous: one port used size_step = distance / 17 and skipped four
of thirteen sizes; height * 51 / (distance + 51) gives the native size at 110 units and
reaches every step. When an object moves from a "far" slot to a "near" slot, make the two
laws meet at the same size, or it visibly restarts.
10.2 Distances must be allowed to go negative¶
Let d go negative for a short window after the camera passes. Clamped at 0, an object
crawls to the last scanline, stays there while the last units run out, then vanishes: the
"object pinned to the bottom of the screen". With d < 0 the projection explodes and the
object sweeps off screen in two frames, as in a real car.
10.3 Positions on a looped track, not decremented distances¶
Give each object a position on the looped track and compute
d = (obj_pos − player_pos) mod LENGTH (length a power of two: the modulo is a mask).
Decrementing a distance has two visible failure modes: an object moving exactly at the
closing speed freezes on screen, and a quantised step (speed >> n) freezes the whole
world below some speed while the car is moving. Use fractional accumulators:
frac += speed; pos += frac >> S; frac &= (1 << S) - 1.
10.4 Anchor by the FOOT, draw dense ladders¶
Every frame of a zoom ladder is bottom-aligned in its box: the bottom edge is the ground contact whatever the size. Anchored by the top, scenery goes into orbit. Four hand-drawn sizes pop visibly; 6–7 steps work, the in-between ones resampled nearest-neighbour from the closest drawn size (a filtered resize invents colours a 4-entry palette cannot hold). The block of cells is usually wider than the drawing: place it by the drawing's centre, not the block's left corner, or one side of the road is offset.
Reference distance of a ladder. The target width is half_road_at_line × 16 / ref. With
ref taken at contact distance, the target climbed to 33 for a largest body of 16: the ladder
saturated — the big body held 30 lines, the next ones 2, 6, 8, 1. With ref at the
shortest distance the table really uses: 22 / 3 / 7 / 7 / 7. What remains is structural (the
biggest body cannot grow further without a bigger drawing).
10.5 The player's car stands on a slice¶
The player's car is not drawn at the bumper but a few slices up the band, so its image already corresponds to a distance. Two consequences, both measured on the OAM (bottom of body against bottom of body):
- Grid alignment: a rival at logical gap 0 appeared 9 px lower (closer); the gap that aligns the two cars had to be measured (12 units in one port), and later the right fix was structural: project the rival from the depth of the player's wheel line, read in the road's distance table, and start at logical equality.
- The finish verdict must use the same gap as the image. The race was judged before the rival's update for the frame, i.e. on last frame's gap: a rival going from +2 to −8 on the last update lost the player a race they visibly won. Judge after the rival moves.
10.6 Two cars crossing: priority group first, then the lowest slot¶
The K2GE resolves overlap in two steps: first the priority group (PR.C: front > middle > behind), then, within a group, the lowest OAM number wins. A two-layer player car (lights in 0..11, body in 52..63) and a rival in 12..19, all front: the rival slides between the two layers whatever car is nearer. The recipe, decided every frame on the displayed bottoms (the lower bottom on screen is the nearer car):
| case | rival | player car |
|---|---|---|
| rival further | its usual slots, middle priority | unchanged, front |
| rival nearer | slots lower than the player's, front | layer 1 shifts up accordingly |
Do not put the PLAYER in middle priority to solve it: it would go behind every roadside sprite. And compare displayed bottoms: the player car rises and falls with the hills, a constant line is wrong in the dips. The same rule decides roadside posts vs rival: put the roadside block in higher slots than the rival or posts get drawn in front of it.
10.7 Contact is measured in world units, not in sprite overlap¶
A contact gate calibrated on sprite overlap fires metres early in fake 3D: a distant
sprite is drawn higher but not smaller than the player's 24-line body, so boxes overlap while
there is tarmac between the cars. Compute the scale instead of guessing it: the rival
advances speed × 137 / 2048 units per frame, the HUD shows speed × 2 / 5 MPH, the loop
runs at 30 fps ⇒ 255 = 102 MPH = 45.6 m/s for 17.06 units per frame = 512 units/s ⇒ 1 unit
= 8.9 cm, a 4.2 m car = 47 units. The old gate (128) was 11 m; at 48, contacts happen
with the sprites touching. Keep the gate in the same unit as every other rate — when the
scroll rate changed by 10 %, a unit became 9.9 cm and the gate had to follow.
⛔ A gate can confirm itself. Each rear hit pushes the cars apart by a fixed amount, so the player was sent back to exactly the gate distance every time it got there — 780 frames out of 1 500 between 128 and 160, almost none below. A histogram of the gap shows it; a hit counter does not.
⛔ Against the AI, side-rubbing may never happen: measured 0 in 701 frames — the pursuit keeps the rival ahead and the frames where both are level are exactly those where they are apart laterally. A side-contact rule is only reachable with two humans (link). And a "glitch" at contact was a rear hit where only the speed dropped and nothing moved on screen: a small forward separation fixed it (contact box 146 → 25 frames, rival drawn inside the player 7 → 0).
Impulse, not force. Pushing the rival "4 units per frame while contact lasts" is a FORCE: it never stops and turns the rival into a trailer — rightly rejected once. A reserve that is spent over a few frames (an IMPULSE) is correct. Before reopening an idea closed by a measurement, check whether it was the quantity or its SHAPE that was judged. Similarly a side bump delivered in ONE frame (14 px at once) reads as a teleport; the steering itself moves ~7.5 px per game frame.
10.8 Scenery cost is FIXED per object¶
Two scenery objects are projected, placed and hidden every frame whatever their distance. So
spacing them out plateaus (lost frames 35 → 23 → 20) while removing one object does much
better (21), and removing all scenery leaves 4 — the rest is road and rival. To go faster,
remove work per frame, not density. In the same pass, free gains that changed no pixel:
cell column/row without % and / (two hardware divisions per cell), road top/bottom read
once per frame instead of per object, board sizes read once per object: −842 cycles/turn.
10.9 Everything that moves with the road shares ONE rate¶
Road/profile advance, the rival's own track loop, roadside markers and props each had their own step derived from speed. Slowing the road by 10 % without the others made the scenery slide against the tarmac. Put the same factor in every counter (and in the contact gate, §10.7). Keep a list of them — there were four, not two.
11. The driving model¶
11.1 The camera IS the car — work in screen pixels¶
The player sprite does not move on screen; the player holds a lateral position in pixels and the world slides under it (added to every entry of the scroll table). Work in screen pixels: the usable half-width of the road is measurable on the art, and any other unit forces a conversion that ends up wrong (symptom: the off-road warning fires while the car is visibly on the road).
11.2 Steering vs drift — and the one line that kills difficulty¶
car_lat += (STEER * (speed >> 4)) >> 4; /* the wheel */
car_lat += (bend * (speed >> 4)) >> 4; /* the corner */
Both proportional to speed ⇒ their ratio does not depend on speed. The wheel out-pulled the hardest corner by 30 % at every speed: going faster did not make a corner harder, so slowing down could never be rewarded. Measured: a trivial bang-bang controller with no look-ahead and no braking kept the car within 18 % of the road width flat out. No tuning fixes that; it is the shape of the model. Two ways out, both used:
- Grade corners by the ratio drift/steering at full speed: ≈0.5 (small taps suffice), ≈0.9 (holdable from the right line), ≈1.4 (steering cannot hold: lift or leave). Verify with a perfect servo pilot: easy and medium stay near the centre, hard hits the stop.
- Give steering its own speed curve: lateral authority rising from zero to a moderate speed, then decreasing gently at high speed — braking gives authority back (the playtest request "slowing down should let me cross the road faster"), and zero at a standstill. Share that curve with the corner-limit used by the AI and the grip indicators.
Both forces must run on fractional accumulators: in whole pixels per frame they can only be 1 or 2 px, a gentle and a medium corner come out identical, and there is literally no room for a difficulty curve.
11.3 The scroll rate must follow the speedometer without steps¶
speed >> 5 on 0..255 gives 8 steps: two speeds 30 apart scrolled identically and
acceleration was seen as jumps. A step in 4096ths with a carried remainder follows the speed
continuously; the gain measured +12 % at full speed and +45 % at mid-range (truncation ate
most in the middle). And a constant base step must not move the world at a standstill: ramp
it in over the first ~16 speed units, or the road and the odometer creep while stopped.
11.4 Physics integrated per loop turn¶
If physics advances once per loop turn, every performance gain makes the game faster in real time, not only smoother (going from 2.33 to 2.00 VBlanks per turn = +16 %). Decide it knowingly; retune per-frame constants after any big speed-up (acceleration, steering, centrifugal, scenery speed were all tuned against a loop 5× too slow in one prototype). The race clock must count real VBlanks, never loop turns. A time is only a time for the track AND the pace it was set at: bump the save's layout version when either changes.
11.5 Gearbox, slope and shop: a stat must reach a byte¶
- Automatic shift points written as constants (
{40, 77, 114, 142}) for all cars: two cars whose 3rd gear topped out at 110 and 100 could never leave 3rd — stuck at 44 and 40 MPH all race, invisible in manual (the lever has no speed condition). Derive them from each car's ratios (up at 230/256 of the current gear's ceiling, down at 166/256 of the previous). - The slope lowers a gear's ceiling, so a threshold taken on the flat ceiling becomes
unreachable on a climb (a gradient of 2 blocked 1st gear). Take the threshold on
min(nominal, current). - Three links, none deducible from the others: does the shop write the save? does the save reach the copy the physics reads every frame? does the driving change? Checking only the first and the last leaves the hole where a stat reaches no byte (a tyre-noise threshold read the catalogue car instead of the tuned one; an upgrade overwrote another stat).
- Top speed cannot be measured downhill (the slope raises the ceiling to the same cap for every car) — but do not conclude "measure on the flat" and stop there: the player drives tracks that descend, and saw no difference from the upgrade. Give each car its own downhill ceiling. A measurement trap explains a number; it does not excuse ignoring where the player plays.
- Measure drift at a track POSITION, not after N frames: a car that accelerates better arrives earlier in the corner and seems to handle worse.
11.6 Off-road, verge, and comparing strategies with bots¶
- Off-road braking must stay below acceleration, or grass is a trap you never leave.
- "Bouncing off the verge costs less than braking" was settled with scripted drivers: clean, brakes early, brakes/lifts at three quarters, and one that only corrects at three quarters and lets the verge send it back. Braking too early was as slow as bouncing — which is what a hurried player feels. The fix was an entry hit paid at each exit (punishes repeated bouncing without making a single exit heavy); a stronger drag had already stopped the car dead.
- A car stopped far off the road cannot steer back (steering is zero at a standstill): after ~3 s nearly stopped and off-road, re-centre it laterally, no distance gained, speed zero, first gear.
11.7 Steering pose and steering force: ONE function¶
The car's steering pose rose notch by notch but snapped back to straight on release — the return is half the motion a player actually watches (corner exit). Fix: step the pose back down one notch at a time, and apply to the physics the fraction the displayed notch is worth. One function computes the notch; the drawing and the physics read it. Two formulas for the same thing drift at the first tuning, and the car turns one notch while drawn in another. Tune the per-notch delay on the OAM: at 4 frames the return took 29 frames (the car trailed the wheel); at 2, about 20.
12. Ground, verge and scenery art¶
- The verge is the BACKDROP register, when the road board uses colour index 0 for its grass: index 0 of a plane is transparent, so what you see beside the tarmac is the backdrop colour — one flat colour across the screen. Writing a sand colour into the road palette changes nothing; set the backdrop per track. Measure that colour on the last visible row of the panorama, or there is a horizontal seam at road level. A texture on the verge requires the board to stop using index 0 for grass.
- At the very top of the band the road is ~9 px of half-width; depending on rounding a line catches none of it and comes out as full-width verge (the backdrop). Give the top 3 lines to the scenery.
- Tile 0 of a plane in FRONT must be fully transparent, or the "cleared" plane is an opaque wall in front of the road.
- Ground plane: the edge of a painted road is transparent. Either rewrite a palette entry per scanline, or put a plain ground on SCR1 with the road on SCR2 in front — SCR1 is never sheared, so the ground stays still while the road curves over it.
- The scenery does not come from the road board. The board is placed from its road row, so its top rows are never read; the scenery must be a separate panorama. A panorama is 256 px wide (the plane width) because it wraps when the car turns: extend drawings to 256 by measuring slopes and edges, do not centre a 160-px drawing.
- A tunnel is the cheapest scenery there is: no horizon, so none of the §9 frontier; flat areas and two straight edges, and identical cells cost once — 318 characters of 512 against 508 for a ripped canyon. The vanishing line of the drawing is not the track's: move the opening up to where the road actually starts.
- Start lights in 4 characters: the five frames of the light had exactly the same shape (only the lamp colour changed), so one image is loaded and a state is a palette write. Let the generator find the changing pixels by comparing the frames.
13. What it costs on this CPU¶
| CPU ISR on Timer 0 | MicroDMA | |
|---|---|---|
| cost | one interrupt entry × 152 per frame | 0 CPU cycles per line |
| measured on a prototype | ~10 fps | ~55 fps |
A commercial engine does it with a 3-instruction handler (bank switch, post-incremented
pointers kept in a register bank, reti); an ISR written in C costs 20–50× more. In a C
project, use the MicroDMA. ⚠️ It is one-shot: re-armed anywhere but at the very start of
VBlank, the top of the screen is drawn with the channel's leftovers (the fan started at
line 98, so a corner arrived at a quarter of its amplitude). Use one parity source for
all zones of a double-buffered table: one engine picked the band buffer from one counter and
the horizon buffer from another, and one frame in three wrote the horizon into the displayed
buffer.
The four fixes that took a prototype from ~10 to ~55 fps: MicroDMA instead of the C ISR;
a precomputed band-index table (a mul and a division per band per frame); precomputed
perspective tables (two more divisions per object per frame); fixed lines written once
(64 useless stores per frame). ⚠️ After such a gain, re-tune every per-frame constant.
The 30 fps budget is 2 VBlanks ≈ 204 800 cycles per loop turn. On a finished racer the biggest items were the slice walk, the profile sampling and the rival; rewriting three road loops in assembly (all in registers) and removing repeated calls took the loop from 3.23 to 2.00 VBlanks per turn on every track. Method, cost table and the equivalence gate that proves an optimisation changed nothing: Measuring Performance §4.6–§5.3. Stack depth in the heaviest race scene was ~300 bytes — measure it, every RAM byte you add comes out of that margin.
14. Symptom → cause checklist¶
| Symptom | Real cause |
|---|---|
| everything slow, mushy controls, crawling scenery | the loop misses most VBlanks — measure the frame rate first |
| top of screen frozen, corner at a quarter of its value | MicroDMA re-armed too late in the frame |
| sprites invisible | flags = 0 = hidden priority |
| cars drive under the road | middle priority while the road plane is in front of the ground |
| a frozen copy of a sprite left on the road | metasprite draw writes only its count slots — hide the rest |
| scenery falls from the sky | linear placement instead of 1/d |
| scenery in orbit | sprites anchored by the top of their box |
| object pinned at the bottom of the screen | distance clamped at 0 instead of going negative |
| world freezes at low speed | quantised step speed >> n without accumulator |
| an object stays frozen on screen | decremented distance instead of positions on the track |
| off-road warning lies | lateral and half-width in different units |
| grass is a permanent trap | off-road braking ≥ acceleration |
| corner changes sign mid-bend | u8 table read back as s8 — keep a signed shadow |
| corners impossible to chain | profile and world advancing at different rates |
| gentle and medium corners identical | per-frame integer forces, no accumulator |
| the corner switches on instead of arriving | one curvature for the whole band — §7 |
| every corner drawn straight | gain constant overflowed a 16-bit int — §7.4 |
| the car slides sideways when it lifts | X read at the box bottom line, which shears — pass the wheel line separately (§8.6) |
| the car stays nailed while the road tilts | car placed on the bumper, not on a slice (§8.6) |
| the top of the road flickers | five causes — §9 |
| rival visibly behind but you lose | verdict taken before the rival's update, or projection origin not the player's wheel line (§10.5) |
| contact fires with tarmac between the cars | gate calibrated on sprite overlap, not world units (§10.7) |
| scenery slides against the tarmac | one of the rate counters not scaled with the others (§10.9) |
| two ROMs compared pixel by pixel diverge from the start | the VRAM queue runs at its budget and shifts by one frame — compare the OAM, not the screen |
See Also¶
- Effects and Raster — the per-scanline mechanism, hills, and how to debug a raster effect
- Tilemaps and Scrolling — the plane registers being written
- DMA — driving the per-line table at zero CPU cost per line