DOWNHILL FROM HERE

A homemade soapbox derby with no accelerator, one pedal marked PAUSE, and a gauge on the hood reading P(DOOM). The higher that needle climbs, the more upgrade crates the crowd throws onto the road.

Desktop:
A/D or arrow keys to steer. Space is PAUSE. There is no accelerator: gravity is the gas, and PAUSE is the only pedal you have.
Mobile:
Drag anywhere to steer. The big red pedal at bottom-left is PAUSE. That is the entire control scheme.
Rate this game

No account needed.

Devlog

A researcher quit an AI lab yesterday and said the industry is gambling with our lives. The lab's own alignment lead reposted it agreeing, and raised the number. Ninety million views. Then everyone went back to work on Wednesday.

The part I could not stop looking at is that the number went up and nothing else changed. Being loudly alarmed about the machine turns out to be one of the recognised ways of being important in the industry that builds the machine. The alarm is a deliverable. It ships.

That is not a tragedy. It is a soapbox derby. A homemade vehicle with no brakes worth the name, a hill, a crowd that came specifically to watch you not slow down, and a man at the bottom with a megaphone explaining that this is far too dangerous and should be stopped, right before the next heat.

So there is no AI anywhere in this game. There is a hill, two plywood carts, and a brass gauge bolted where a speedometer goes. The gauge reads P(DOOM). The crowd came to watch it climb, and the higher it goes the more upgrade crates they hurl onto the road, and every crate makes you faster and pushes the needle higher. There is exactly one pedal. It says PAUSE. It works perfectly. You will almost never touch it.

The cart does not live in the world

The first decision was the one everything else fell out of: the cart is not a rigid body in world space. It lives in road space.

Its position is (s, n): arc length along the centreline, and lateral offset from it. Its heading psi is measured against the road's tangent, not against north. The equations of motion are then

ds/dt = v cos(psi) / (1 - n * kappa)
dn/dt = v sin(psi)
dpsi/dt = yawRate - kappa * ds/dt

where kappa is the road's signed curvature at s. Everything the game needs is one-dimensional after that. Where to put a hay bale: an s and an n. Whether you hit it: compare two numbers. Where the crowd stands: n = halfWidth + 1.15, four ranks. What the rival is doing: one number ahead of or behind yours. Where the camera goes: s minus six.

The part that surprised me is that it does not cost you any feel. psi is free to diverge from the tangent, so understeer and oversteer are not scripted, they fall out of a grip limit: required lateral acceleration is v^2 * kappa, and when that exceeds what the tyres have, the excess bleeds into lateral drift and psi runs away a little. Counter-steering recovers it because counter-steering genuinely reduces the required acceleration. I did not write a drift mechanic. I wrote a grip ceiling.

Curvature is an input to this generator, not something recovered from a spline. That matters more than it sounds. If you lay out a track as control points and fit a curve through them, curvature is a second derivative, which means it is noisy, which means it fights you at the exact moment the game asks its only question. Here I lay out a sequence of straights, bends and hairpins with clothoid ramps between them and integrate heading and grade along arc length. The hill comes out the other end: 3.7 km, about 300 m of drop, four hairpins at 72, 62, 55 and 46 m radius. Every corner's honest hold speed is sqrt(grip * radius), computed exactly, and the game knows it before you get there.

Crests fall out for free. If the road drops away faster than gravity can pull the cart down, the cart is airborne. That is one comparison, and it bought me jumps.

Gravity is the gas, and gravity was too slow

I built the longitudinal model honestly. Gravity along the grade, quadratic drag, rolling resistance. A real soapbox on a real eight percent hill accelerates at about 0.65 m/s². The first full run took 206 seconds.

The target was 105.

My first instinct was that drag was too high. It was not. The problem is the shape of the curve, not its endpoint. For dv/dt = a0 - k v², the speed approaches terminal as a hyperbolic tangent with a time constant of v_terminal / a0. With honest numbers that is 32 divided by 0.65, which is forty-nine seconds. You would spend the first minute of a two-minute game watching a needle not move, and it would be completely accurate.

The fix is a gravity multiplier of 3.4 with drag raised to match. That leaves every terminal speed exactly where I chose it: 32 m/s on a bare cart, 43 with three upgrades bolted on, 58 fully loaded, which is precisely the speed I mapped to 100% on the gauge. And it collapses the time constant from forty-nine seconds to twelve. The hill still pulls you. It just stops taking a coffee break about it.

Every upgrade also adds drag, which is both funnier and more correct: strapping an oil drum to a plywood box is not an aerodynamic act. The last crate you take is worth noticeably less than the first, which keeps the snowball from running away and gives the fully loaded cart a top speed that lands on the number rather than sailing past it.

The pedal has to be good, and it has to cost you

The one non-negotiable in the whole design is that PAUSE is not a trick. If it were a trap pedal the joke would die and the game would be unfair. It has to be the correct play at a hairpin, and it has to always cost the lead.

That is an arithmetic problem, so I did the arithmetic. The worst case on this hill is arriving at the fourth hairpin at 58 m/s when it can only be held at 20.6. The entry zone is 106 m. Shedding that much speed in that much distance needs

(58² - 20.6²) / (2 * 106) = 13.9 m/s²

of net retardation. The pedal is set at 15, which nets about 15.4 with drag helping, and takes 95 m. From a more typical 45 m/s it takes 53 m, half the entry zone. Comfortable, decisive, and it never fails to save you. It also steadies the cart, adds grip and pulls the heading back toward the tangent, so the frame stops juddering the moment you touch it.

And it stops the crates dead. The crowd is not interested in a cart that is slowing down.

The rival matches every threshold you cross a beat later and adds a little. When you PAUSE, it hesitates: it lifts 55% for half a second and twitches the wheel so you can see the flinch from behind. Then it doesn't. It only falls for it once every four and a half seconds, which is about how often you deserve it.

Three things I got wrong by being right

The exhaust. Real exhaust leaves the nozzle carrying most of the vehicle's speed and sheds it over the next half second. So that is what I built. At 56 m/s with seven thrusters lit, a plume that keeps ninety percent of the cart's velocity never leaves. The road disappeared. The next hairpin disappeared. My own cart disappeared. The puffs now keep barely half of it and shed that fast, which is wrong, and which is the only way a chase camera has ever seen a plume.

The lens. I started at a 58 degree field of view widening to 78 at speed, on the theory that a wide lens sells speed. It does not. The cart was nine percent of the screen width and the whole shot read as a fisheye of an empty road. The concept art I was working from is a longish lens: the hill compresses, the road stacks up toward the vanishing point, and the two carts are substantial in frame. I dropped it to 46. Speed comes from the lens changing, not from it being wide.

The sun. I put the key light at 51 degrees of elevation, which is a nice sensible mid-morning sun. Then I spent an hour trying to work out why the tarmac looked like a flat grey strip when every reference image I had showed a road covered in dappled shadow. A ten metre tree at 51 degrees lays its shadow down eight metres away, which is the verge. At 33 degrees it throws fifteen metres, which is across the road. The shadows are the texture. There was nothing wrong with the road.

Two things were broken in a way that looks like an art problem

There were no trees in this game for about two hours, and I spent most of that time adjusting how the trees looked.

React StrictMode is on for this site, which means every component mounts, runs its effect cleanup, and mounts again. The scenery built its whole valley in a useMemo and disposed it in an effect cleanup. The cleanup ran, the geometry was freed and the group emptied, and then the second mount reused the same useMemo value, which was now a corpse. useMemo does not re-run on a StrictMode remount. Scenery contributed exactly zero objects to the scene, no error anywhere, and every screenshot I had been reading as "the canopies are too small" or "the density is too low" was in fact "there are no trees at all".

What found it was not looking harder. It was walking the live scene graph and printing instance counts and triangles per mesh. The largest instanced object on screen was 168 copies of a twelve-triangle mesh. That is not a forest, and it takes one line of numbers to see and no amount of squinting.

The second one is worse, because it is invisible at every level a debugger can reach. Nothing in this scene received shadows. Not the ground, not the crowd, not the props, on either quality path, with any material. The shadow map was rendering correctly. receiveShadow was true. The coordinates were inside the frustum. The shader had the shadow uniforms. And getShadow returned 1.0 everywhere.

three.js r185 samples PCF through a hardware sampler2DShadow, and the depth texture's compareFunction is baked in at the moment the map is allocated, from whatever renderer.shadowMap.type says right then. The host <Canvas shadows> allocates it a frame before any child effect can set the type, and r3f's default is PCFSoftShadowMap, which r185 removed. So the sampler was reading a texture with depth comparison switched off in hardware, and every tap came back lit. No shader could ever have seen a shadow. The fix is one line after setting the type: dispose the map and null it, so it gets allocated again with the comparison the sampler expects.

Then the shadows arrived and the road was still flat grey past about eighty metres, which I spent a while blaming on the asphalt colour. The ortho shadow frustum was 68 m wide, and a visible straight on this hill is 200. The dapple was stopping a third of the way down the road. Widening the box costs texel resolution and buys the whole road, which is the entire reason the sun is at thirty-three degrees in the first place.

The valley was being drawn and thrown away

For most of the build the hill ended about two hundred metres out in a pale card. The crest, the valley, the bottom of the run: never visible. I spent a while assuming I had not built enough world, which was wrong. There were already four rings of horizon out there, including landform silhouettes at 350 and 630 metres, and they were being rendered every single frame.

Fog is what erased them, and the reason is the curve rather than the distance. The fog factor is a smoothstep, not a linear ramp, and a smoothstep spends its steepest section exactly in the middle of its range. Mine ran 85 to 620 metres. Evaluate it at the far ring: smoothstep(85, 620, 630) is 1.00. Not 0.9, not nearly gone. One. That geometry was drawn, shaded, and then multiplied entirely into the haze, every frame, forever. The near ring at 350 metres sat at 0.50, half erased.

Widening to 115 and 850 leaves the near behaviour alone, so a tree at 200 metres still only just hazes, and stops the far half flattening: the near ridge lands at 0.24 instead of 0.50, the far one at 0.78 instead of gone. Both survive as distinct values, which is all a horizon needs.

The other half of the fix costs no geometry at all. Real ridges sit at fixed places relative to the road, and portrait's horizontal field of view is about 28 degrees, so whether one is in front of you is luck. So the sky shader now draws its own two silhouette layers and a serrated treeline as a function of compass direction, at infinity, in every frame. One detail there is worth stealing: sample the ridge on the unit horizontal direction vector rather than on atan2. An angle wraps at plus or minus pi and welds a visible vertical seam into the sky due north, while tracing a circle through a two-dimensional noise field closes on itself exactly and cannot seam anywhere.

Everything is flat colour

There is not a single image texture in the three kilometres of this valley. Every surface is one flat colour and the light does all the work, which is the only reason this holds sixty frames on a phone.

That only works if the colours are chosen once, together, and never eyeballed per-mesh, so the whole art direction is sixty lines: a palette file with about forty hexes and a light rig, and a rule that if a colour is not in it, it is not in the game. Hay gold, rust orange, road grey, field green, autumn amber, and exactly one hot red, reserved entirely for the pedal.

The trees are the one thing that has to be built rather than coloured. Zoom into any flat-shaded game whose foliage you admire and the canopy is not a smooth ball, it is a loose cloud of dozens of small overlapping blobs with gaps you can see sky through, and the colour varies blob to blob inside a single tree: brighter and yellower on the sunward crown, deeper orange underneath. That scatter is the entire trick. A smooth low-poly canopy reads as programmer art no matter how good the palette is.

The crowd is 42 triangles a person, seven hundred of them in a rolling window, and the note I like best in the whole codebase is on the body geometry: nearly cylindrical, not a wedge, because a spectator in October is a bundle of coat with boots under it and a strong taper reads as a skittle at any distance. The heads are deliberately oversized. A correctly scaled head is four pixels of nothing at sixty metres, and that warm dotted line along the top of the crowd is the only thing stopping the whole band reading as a hedge.

Half of them hold a placard reading SLOW DOWN. Half hold FASTER. They flip as you pass.

There is no post-processing library in this project and I did not add one, so the bloom, the tone curve, the grade, the vignette and the speed smear are one composite pass by hand: the scene into a half-float target, a quarter-resolution bright pass, two separable blurs, and one shader that does the rest. The reason it can be that cheap is a detail worth knowing: three.js does not apply tone mapping when it renders into a render target. The scene target holds linear values above 1.0, so the bright pass has something real to find. Bloom applied to already tone-mapped output is the usual mistake, and it always looks like a filter rather than like light.

My own test rig lied to me for forty minutes

I drive this game with a bot. It reads the simulation directly, picks a line, and works the same two controls a person has, so I can run a full hill and get back speed, crate count, crash count and a fistful of screenshots without touching it.

Late in the build the bot told me the game had no engine. The cart sat at 19 m/s. One upgrade in two kilometres. The needle never crossed 40%. For a game whose entire premise is a number climbing, that is a death certificate.

Every word of it was false, and the fault was mine.

The bot steers by pressing arrow keys. The HUD ramps a held key toward full lock with a time constant of 45 milliseconds, because a keyboard has to be turned into an analogue axis somehow and a cart that snaps between locks is not a cart. So a controller politely asking for 0.13 of steering got 1.0. Full lock saturates the grip ceiling, grip saturation is slip, and slip scrubs off longitudinal speed. The bot was driving the whole hill with the wheel yanked to the stop, then reporting back that the hill was flat.

The experiment that settled it took four lines: start the race, press nothing at all, and read the physics every quarter second. In a game where gravity is the only input, pressing nothing is the cleanest test available. Slip held at exactly zero and the cart went 13 to 24 m/s in seventeen seconds with the grade steepening under it, which is the curve I designed.

The fix was not a fudge factor, it was reaching for the right actuator. Touch steering is already a real analogue axis: the touch-down point is the origin and lateral distance maps to the wheel. So the bot holds a pointer down and slides it, and as a bonus it now exercises the input path a phone actually uses, which is the platform I am aiming at.

Two more numbers were wrong downstream of the same mistake, both in the same way. They were distances, tuned while the cart crawled. Fifty-five metres of obstacle lookahead is generous at 19 m/s and is a second and a half at 35. The brake planner assumed the pedal's full 15 m/s^2, forgetting that pressing PAUSE does not switch off the hill: gravity keeps supplying 3.4 and every upgrade keeps thrusting, so real retardation is nearer 11 and the bot arrived at every corner carrying too much. Lookaheads want to be measured in seconds.

Then I read a minus sign backwards and believed myself for an hour

With honest instruments the bot went back down the hill, and I pulled the summary apart looking for the next thing to fix. rivalGapM: -807. The rival, I decided, was 807 metres behind, which was interesting, because the bot had crashed 31 times to get there and had touched the brake for a total of four seconds out of two hundred. If a driver who never brakes and crashes constantly is still winning by most of a kilometre, then braking costs more than crashing, and the pedal the entire game is built around is a trap. I had a diagnosis, a mechanism and a fix inside ten minutes.

The gap is computed player.s - rival.s. Negative means the rival is ahead. The rival had been beating the bot by 807 metres, which is the opposite fact, and it makes the opposite argument: the cart that brakes properly for every corner was walking away from the one that does not. The pedal was fine. It had been fine the whole time.

I only caught it because the first run that made it all the way to the bottom printed rivalWon: true next to a gap I was reading as a win. Two fields that could not both be true is a cheap thing to check for and I would not have looked otherwise.

What I want to keep from that: the harness bug and this one are the same bug. Both times a number came back, both times I built a theory on top of it without asking what the number meant, and the theory was elaborate enough to feel earned. Forty minutes on the first, an hour on the second. The reason the first one broke was that I ran an experiment instead of reasoning. The reason this one broke was a contradiction I could not explain away.

The crash discount

The fix I had already made turned out to be worth keeping, for a smaller and more honest reason than the one I invented for it.

On impact a cart keeps max(v * 0.55, respawnV), and respawnV was 11 + thrust * 2.4. A loaded cart restarts hot. The comment directly above it promised that a crash "always costs about the same fraction of your momentum," and the constant did not deliver that: on a full set of eight upgrades the floor is 27.6 m/s, which beats the proportional rule outright. So the discount grew with the loadout. The risk curve flattened exactly where the game wants it steepest, and at a hundred percent on the gauge wearing a jet pod, crashing was the cheapest it ever got.

Dropping the coefficient to 0.6 hands the job back to the fraction, which is all the comment ever claimed. Same hill, same seed: three upgrades became eight, a 78 percent peak became 89, and time on the pedal went from four seconds to ten. The tumble is untouched, still under two seconds, still enormous, still non-fatal. Ground, not progress.

Gravity is the gas was a description, not an invariant

The rival is the second cart on the hill and it is meant to be a pressure source. Late on I found it doing 33.9 m/s, then 20, then 15, then nothing. It came to a complete stop at 1020 metres and never moved again. It had not crashed. It just parked, and the player drove past a stationary opponent for half a minute.

Three separate mercy mechanisms stack on a rival that gets too far ahead: negative thrust, extra drag, and an occasional deliberate mistake on the pedal. Any two of those are a rubber band. All three at full stretch are a handbrake, in a function whose own comment promises the band never compounds on itself.

But that only explains the deceleration. It does not explain why zero was a place it could stay.

The hill has three crests, short uphill kicks meant to unweight the cart at speed. They reach plus ten percent. In a game with no accelerator, an uphill is a trap: rolling resistance costs 0.14 m/s^2 and a ten percent climb only gives back 0.55, so any cart that arrives at a crest with nothing left is stuck there permanently, and there is no pedal anywhere that would help. Sweeping the grade profile across five seeds, about 150 metres of every 3.7 kilometre hill sits above that threshold. The player is saved from it only by the habit of arriving with momentum.

So the fix belonged in the hill, not the rival. The grade is now clamped so the road never climbs more than two percent, comfortably more pull than the 1.26 percent it takes to shift a cart sitting in wet grass with rolling at 0.42. The crests become flattenings rather than climbs, which is what a real hill crest does anyway, and the drop away on the far side is untouched, so they still throw the cart into the air: best airtime went from 1.32 seconds to 1.06.

The thing I had wrong was not a number. I had been reading "gravity is the gas" as a description of the design, and it is actually an invariant. If the only input is the slope, then every metre of road has to supply more than the cart can lose, everywhere, or the game can silently stop being playable. Nothing in the code was enforcing that, so I wrote it down as a constant with the arithmetic next to it.

The bottom

Score is your time. The flex is your peak P(DOOM), printed enormous on the results card, above the finish time and above the position, because that is the number the crowd came for.

Then the winner takes the megaphone to warn everyone that this derby is far too dangerous and must be stopped, and sign-ups for Heat 2 are by the bales.

Watch

DOWNHILL FROM HERE · 4:27

Leaderboard

Loading…