Games › Fresh Powder

How Fresh Powder checks every run

Fresh Powder is a calm sledding game where every run packs down the snow for the next rider, and everyone rides the same hill each day. So every run a player uploads changes a world other people ride in. Before it does, the server checks the run with the game's own simulation code. This page walks through how a run gets from your phone into everyone's snow, what gets turned away, and what I'd still change.

Live on play.issam.site since 30 September 2026
Chasing four ghosts, which are replays of recorded runs
In one minute
What I did
Designed and wrote the online side: the run format, the upload API, guest and signed-in accounts, the merge job and its checks, and the deploy.
Built with
Godot 4 and GDScript, Django REST framework with SimpleJWT, Nginx, systemd timers, and the game itself running headless on a Linux server.
What came of it
Live since 30 September 2026. In tests it turned away 10 of 10 tampered runs and none of 40 honest ones, at about 8 ms per run.
Not done yet
It checks whether a path is possible, not whether the sled's physics would produce it. Replaying the top times is next. No load test yet.
8 msper run to check it and press it into the snow, once a batch has started. The tests merged 36 runs in 299 ms on my PC; starting a batch costs about half a second.
5 minbetween merges. The server checks the new runs and adds them to everyone's snow in one batch.
10 of 10tampered runs, in 8 categories, were rejected in the merge's tests. I made each one by editing a real run. None of the 40 honest bot runs was rejected.
99 testscover it: 89 in the Django app and 10 in the merge. A 30-check loop in a headless browser runs the whole path end to end.

How a run gets into everyone's snow

Nothing is sent while you ride. A finished run goes up as one small file, the server folds the new runs into the day's snow every five minutes, and the snow comes back down as plain files that Nginx (the web server) hands out without touching the backend. The backend is a Django app, written in Python with Django REST framework and SimpleJWT.

The game, on your phone or in your browser
1Records your run as you ride: where you are 10 times a second, and what you press 60 times a second. A 46 s test run came to 5,719 bytes once gzipped (compressed).
2Uploads the file when you finish, with a random device ID it made the first time you played, and a sign-in token if you're signed in. If the upload fails, the run waits in an outbox and tries again, first after 15 s and then less often, up to 15 minutes apart, always with the same run ID.
6Picks up the new snow at the start gate before your next run, never in the middle of one.
The server
3Django checks the cheap things first: the file's header, its size, the date, the token and how recently this device sent a run. Then it saves the file and tells the game when the next merge will include it.
4Every 5 minutes a timer starts the merge. Django lists the new runs in the order they arrived and starts the game itself on the server, headless (with no window or graphics, only the code).
5The merge checks each run, presses the good ones into the day's snow grid, and picks the ghosts and the leaderboard. Django then saves every run's result in one database transaction, so either all of it is recorded or none of it is.

Who sent it: guests and accounts

You can play without an account. The first time you finish a run, the game makes a random device ID (a UUID, a long random number) and writes it into every run file. For a guest, that ID is the only key to their runs, so nothing public ever shows it. The leaderboard has no IDs, and the published ghosts have theirs blanked out.

If you're signed in on the site, the page hands the game an access token through postMessage, the browser's way for a page and the game frame inside it to talk. The token is a JWT (a signed note saying who you are) that lasts 10 minutes. The site renews it from a 30-day refresh token kept in a cookie that JavaScript can't read, and the game only ever holds it in memory. When a token runs out, the game asks the site for a new one and tries the upload once more. The first time you sign in, the game links its device ID to your account, so your guest runs come with you.

The game's API accepts only that token, never the site's login cookie. So another tab can't send runs in your name just because you're logged in.

Rate limits

Size
A run file can be 64 KB at most. Django unpacks the gzip with a cap, so a tiny file that unpacks into something huge (a gzip bomb) is refused.
Per device
Each device can send one run every 45 seconds and 200 a day. A run sent too soon gets a 429 (too many requests) that says how many seconds to wait.
Snow
Only a device's first 20 good runs each day pack snow. Later ones still count for the leaderboard.
Counting
The limits are counted from the runs saved in the database, so every server process gives the same answer, and a refused upload doesn't use up a slot. The run ID works as an idempotency key (a label that makes a repeat harmless): sending a saved run again gets its saved result back instead of a copy.
Names
A player can change their name 5 times an hour.

One simulation in two places

The merge isn't written in Python. On the server, Django starts a headless Linux build of the game (made in Godot, the game engine) that carries the same simulation code the game runs: the code that reads run files, builds the day's hill from the date, and presses a run into the snow. If I had rewritten that in Python, the server's snow and the players' snow would drift apart the first time I changed one side and forgot the other.

Sharing the code only works because it's a deterministic simulation, which means the same inputs always give the same result. The simulation code steps at a fixed 60 times a second, never reads the clock and never uses the engine's shared random numbers. The merge makes one random choice, which older runs to show as ghosts, and it seeds that from the date and the merge's version number.

Debug view: bright snow is packed, each line is a recorded run

What I checked

  • I ran the same four merges twice, and all 18 files they wrote came out the same, byte for byte. Merging runs in one batch or in two gives the same snow too.
  • Three whole two-minute rides are hashed every step (position, speed, the snow under the sled). The hashes still match the ones I saved before a big speed-up pass on the simulation.
  • The Linux build of the merge was tested under WSL (Linux running inside Windows) before it went live, and it has merged the live runs since 30 September.
  • In an end-to-end test, two game clients rode and uploaded, and a third downloaded the merged snow. Every position sample where either sled was on the snow, 332 and 576 of them, sat on packed snow in that download.

What could still break it

Computers store decimals as floating-point numbers, and different chips and compilers can round the last digit differently. The server is an x86 Linux machine, the web build runs as WebAssembly in your browser, and phones use ARM chips. I haven't compared the simulation's results across them yet.

Today that matters less than it sounds, because the server doesn't re-run the sled's physics. It checks the recorded path and presses it into its own copy of the snow, and every player downloads that one copy. It will matter as soon as the server replays runs from their button presses, so I'll have to run that comparison first.

What gets turned away

This is server-side validation, the game's anti-cheat: the server trusts nothing inside a run file. Cheap checks happen when the file arrives, and the real ones happen in the merge, with the game's own code. A rejected run is saved with a reason code (bad_header, wrong_day, no_finish, jump, too_fast or rate), and it never touches the snow.

When it arrives, in Django

Broken file
The file doesn't start with the four letters every run file starts with, has an unknown version, has a length that doesn't match its header, or has no device ID. Django answers 400 (bad request) and saves nothing.
Someone else's
The device ID inside the file must match the device sending it. The same run ID from a second device gets a 409 (conflict).
Wrong day
A run on yesterday's hill gets a 409 once the day closes at midnight UTC. A date in the future gets a 400.
Too big
A file over 64 KB, or a gzip bomb, gets a 400.
Too often
A device over its rate limit gets a 429 with the seconds to wait.
Bad token
An expired or forged token gets a 401. The game asks the site for a fresh token and tries once more.

In the merge, with the game's own code

Broken inside
The file doesn't read, the device ID is blank or isn't the sender's, the rider type is unknown, or the number of position samples doesn't fit the number of button inputs (one sample for every six).
Wrong day
The run is on another day's hill, or on another version of the hill generator.
Not finished
The run didn't finish, started below the start gate, or stopped short of the finish line.
Jumps
Two position samples a tenth of a second apart are more than 3.5 m apart, which would be 35 m/s.
Too fast
The path goes faster than 33 m/s over any second, the finish is under 30 s, or the finish time is more than 0.3 s shorter than the button inputs say the ride took.
Too often
Two runs from one device arrived less than 45 s apart.

The limits sit above anything an honest ride can do. The sled's top speed in the simulation is 32 m/s, and the 40 bot runs that ship with the game peak at 28.7 m/s over a second and 2.95 m in a tenth of a second. None of them is rejected.

Tampered runs I fed in

Eight of these are edits of a real run. The other two are junk bytes and a second run sent too soon. All of them go through the merge's tests.

Teleport
I moved the second half of the run 6 m sideways. The merge rejected it as a jump.
Shaved time
I made the file claim 5 s less than the ride took. It was rejected as too fast.
Sped up
I stretched every sample 1.4 times further down the hill. It was rejected as a jump or as too fast (the test accepts either).
Wrong day
I changed the date, and separately the hill version. Both were rejected as the wrong day.
Borrowed ID
I sent a file with someone else's device ID, and one with none at all. Both were rejected as broken.
Unfinished
I switched off the run's finished flag. It was rejected as not finished.
Spam
I sent a second run 10 s after the first. It was rejected for coming too often, while one sent 60 s later went through.
Junk
I sent a few bytes that aren't a run file at all. They were rejected as broken.

Four of them went through a full merge together, and the snow grid afterwards was the same as before, byte for byte. At the door, Django's tests send ten kinds of broken file and a gzip bomb (2 MB of zeros squeezed under 64 KB). Each one gets a 400 and nothing is saved.

What it doesn't catch

These checks ask whether a path is possible. They don't ask whether the sled's physics would really produce it. So:

  • Someone who writes their own run file with a believable line (under 33 m/s, no jumps, the right number of inputs) could pack snow along a line they never rode, or post a time they didn't earn.
  • The merge doesn't check the path against trees and rocks, so a made-up line could go straight through them.
  • One person with many device IDs gets 20 snow-packing runs per ID. There's no limit per IP address yet.

The fix for times is planned: replay the top 20 times from their button inputs through the sled simulation on the server, and mark each one as pending until the replay finishes within 2% of the claimed time. The run files already carry the inputs for this, but I haven't built it yet.

Trade-offs, and what I'd change

One batch every 5 minutes

Starting the engine, building the day's hill and loading the saved snow cost far more than the runs themselves. A merge of 12 runs took about 0.7 s on my PC, and 0.55 s of that was building the hill. The first live merge took about 3 s for its one run. A batch pays that cost once every five minutes instead of once per run.

The price is that your line joins the shared snow up to five minutes later, and the results card tells you so. Because the merge is deterministic, merging the same runs in one batch or two gives the same snow, so changing the interval never changes the result. If the game gets busy, I'd keep one merge process running instead of starting Godot every five minutes.

A day ends on the build it began on

The snow packed so far belongs to the hill it was packed on, so the merge refuses to carry on a day after the hill generator's version changes. That means a deploy that changes the hill should go out just after midnight UTC.

On 1 October I shipped one during the day, when the day had one run on it (mine), and I chose to reset the day. What I'd change is to let a day hold more than one hill version, each with its own snow, so a deploy can go out at any time and riders still on the old build keep counting.

Checking paths instead of replaying physics

Checking a recorded path takes milliseconds and stops the crude tricks. Replaying every run's button presses through the full sled physics would catch far more, but it would cost much more per run (I haven't measured how much), and it only works if the simulation gives the same result on every platform. I'd start with the top 20 times, since those are what people compete for.

Counting in the database, and safe retries

Rate limits are counted from the saved runs rather than an in-memory cache. That costs a database query per upload, but every server process gives the same answer. The merge writes its result to disk before Django records it, so if Django crashes in between, the next merge picks the result up. A file lock keeps two merges from running at once, and a failed merge logs the error and leaves its runs for the next one.

How I tested it

  • The merge has 10 tests in the game's own test runner, tampered runs included. All 10 passed when I ran them again for this page.
  • The Django app has 89 tests covering uploads, rate limits, tokens, the day turning over at midnight and the merge job.
  • A full loop runs in headless Chromium (a browser with no window) with 30 checks: sign-in, the token handshake, upload, retry, the rate limit, and the real merge publishing new snow.
  • Another 25 checks cover the Nginx setup, run against nginx 1.24.

Not measured yet

  • I haven't load-tested uploads or timed a merge of hundreds of runs on the live server. The only live timing I have is the first merge, about 3 s for one run.
  • The determinism tests run on my Windows PC. I haven't compared the browser build, an Android phone and the Linux server.
  • I haven't counted how many runs the live server has turned away since the first one.
  • Nobody has attacked it that I know of. Every tampered run on this page is one I made for a test.

Ride today's shared hill, and when you finish, your run goes through every check on this page.

Today's fastest

Loading the board
See the whole board