wanessalabs games production

Ski Rash

A downhill race on the same hill everyone else got today, against five rivals with a head start and nothing you carry that clears everything in your way.

ReactTypeScriptthree.jsReact Three FiberViteCloudflare PagesCapacitor

I wrote a specification before I wrote any code, and the code proved three parts of it wrong inside a day. Two of those were arithmetic I had done in my head.

What It Is

A downhill race. Five rivals set off ahead of you and ski slower than you can, so the run is a chase: hit the speed rings to close the gap, ride up alongside someone, and knock them into the snow. They talk back when you do.

You carve around trees, rocks, moguls and lift pylons, you take ramps and booters for air, and you carry exactly one tool that you picked before you dropped in. The run ends at the finish line or when the clock runs out, whichever comes first, and what you get is a placing rather than only a number.

Everyone playing today gets the same hill. It changes at midnight UTC.

The Rule That Decided Everything Else

A shared daily course only means something if two people on opposite sides of the world get an identical hill. That is not a feature you can add later. It is a constraint on every line of the simulation.

So the game logic imports nothing. No React, no three.js, no browser. It cannot read a clock, cannot call Math.random, and cannot see the page. Time only moves when something hands it a fixed step, and every random number comes from a seed that arrives from outside.

The whole course is drawn before the first frame. Not “seeded as you go”: every tree, every ramp, every drifting skier and the exact phase of their drift is decided up front, so the random stream can never advance in response to something the player did. That one decision is why a replay test is trivially true instead of quietly fragile.

There is a test that records ninety seconds of button presses, runs them twice, and demands the two final states match exactly.

No Weapon Clears Everything

You bring a snow shovel, a ski pole, or a push broom. The same tree resolves differently depending on which.

Each one has at least one obstacle that will put you down. There is a test that checks this, because a balance table drifts the moment somebody edits it and I did not want to find out by playing.

The Claim I Got Wrong By Seventy Five Centimetres

I wrote in the design document that one shovel swing takes long enough to cover 21.3 metres, that obstacles come as close together as 22 metres, and that the shovel therefore cannot answer two rows in a row.

The real number is 21.25 metres. Which is inside 22. The claim was false.

It is only true at the speed you actually reach if you point straight downhill. Which makes it a better statement than the one I wrote: the shovel keeps up only if you slow down to let it. That is the trade the whole game is built on, and I had accidentally written it as a flat capability limit instead.

Proving Every Hill Is Survivable

A random course generator will eventually produce a wall of trees with no way through. Checking that a gap exists is not enough, because a gap you cannot reach in the distance available is not a gap.

So the generator writes a course and then an autopilot skis it. The autopilot steers toward each opening, flattens its skis to land, and never swings at anything. If it gets to the bottom without falling over, the course is clearable by dodging alone, which makes the guarantee true for all three tools at once.

It is deliberately worse than a real player. It rejects some courses that are perfectly playable, which only makes the accepted set slightly easier. It never accepts one that is not.

Two hundred sampled seeds, two hundred accepted.

That last number is the uncomfortable one. I built a re-roll loop and a difficulty fallback for courses that fail the check, and across two hundred seeds neither has ever run. There is now a test that fails if the re-roll rate climbs above five percent, so if I ever start leaning on that machinery it will be a decision rather than something I find out later.

Something That Cancelled Out

I expected the hard part of the course generator to be that faster players have less time to react.

Working it through, the reachable sideways distance between two rows is the sideways speed multiplied by the time available. Sideways speed scales with how fast you are going. Time available divides by how fast you are going. Speed cancels out completely. The answer depends only on how far apart the rows are.

The intuition was not slightly off. It was backwards.

Four Bugs That Passed Every Test

The simulation had a hundred and thirty passing tests before anything was drawn on screen. Then I drew it.

The entire mountain was tilted the wrong way. I had rotated the ground by plus an angle where the maths wanted minus. Nothing threw an error. It rendered, and it looked plausibly wrong.

Not one obstacle was visible. Drawing hundreds of trees efficiently means batching them into a single object, and that object keeps its own position at the origin while the trees live hundreds of metres away. The renderer decided the whole batch was off screen and skipped it. Silently. Every frame.

The button at the end of a run did nothing. The steering area covers half the screen and tells the browser to ignore taps so a drag does not select text. The button sat inside that half. Same for the keyboard: pressing space to activate it was being swallowed by the jump control.

The title on the hub card was the wrong colour. This one survived everything above, plus the deploy. Each game has an accent colour, defined once as a variable and referenced by a utility class. I added the variable. The build tool needs the name registered in a second file before it will generate that class at all, and I had not done that, so the class existed in the markup and matched nothing. The title fell back to plain ink.

I found it by looking at the live page and thinking the card looked flat. Two other games have had the same fault for weeks.

None of these were findable by a test I would have thought to write. Three came from opening the page and looking at it, one from clicking a button after sitting through a full ninety second run, and one from squinting at the site after it was already public.

The tilt is now checked as geometry, and the check has a companion that proves the old wrong version genuinely fails it, so the guard cannot pass either way.

The Camera Was Unreadable And The Arithmetic Said Why

Even with the obstacles drawing, the hill looked empty.

The camera sat three and a half metres above an eighteen degree slope, which means it was looking almost straight along the ground. The angle between something twenty metres ahead and something a hundred and ninety metres ahead worked out at eight and a half degrees: fourteen percent of the screen. Everything was rendering. It was rendering into a band a few pixels tall.

Lifting the camera to seven and a half metres spreads the same stretch of hill across a third of the screen. Higher than a real chase camera would sit, and the right call, because you cannot dodge what you cannot resolve.

Colours That Were Quietly Fiction

The scene reads its colours out of the stylesheet, so nothing is hardcoded. Except that the code also carries a fallback copy of each colour for the test environment, where there is no page to read from.

I wrote those sixteen fallback values by eye.

All sixteen were wrong. The forest green I had written was a noticeably different colour from the forest green in the stylesheet. It never mattered in a browser, which reads the real values, so it would have sat there being false indefinitely.

There is now a test that converts every stylesheet colour through the same conversion the renderer uses and demands they match exactly.

Crashing Costs You Time, Not The Run

You cannot be knocked out. Hit a tree and you lose one and a half seconds on the ground, most of your speed, and your entire combo multiplier.

That was a deliberate choice against the more obvious one. If a single mistake ended the run, a shared daily hill would reward whoever got lucky in the first ten seconds, and a multiplier that builds over a run would have nothing to build over. The punishment is still roughly five percent of your time plus everything you had accumulated, which is enough.

What It Does Not Do

No accounts, no leaderboard, no backend, no ads, no tracking. Your scores and your best placing live on your device and nothing leaves it.

I checked that twice. Once by banning the network functions in the source, and once by recording every request the page made across two full runs. The only non-asset request turned out to be the confetti library building itself a worker in memory when the run ended.

There is no art beyond gray boxes and one logo. Trees are cones. Rocks are lumps. The physics is legible and the mountain is not yet beautiful.

Nobody has played it on a phone. The touch controls are geometry-tested and driven by synthesised taps in a headless browser, all three controls sit inside a single thumb’s reach, and none of that is the same as a person holding a phone. The weapon balance is arithmetic I did at a desk. It has not survived contact with anyone yet.

Development timeline

1 logged update on 30 Jul 2026.

  1. Race ninety seconds down the mountain in Ski Rashlaunch

    Carry a weapon while you race, in a new run every day or free play whenever you like. Scores stay on your device.

Written from the release notes published with each update, not afterwards. See every project →