Playing
Rewards and seasons
The layer between finishing a race in a browser and having a racer with a provable career on chain. Three stages, deliberately separated so the weak link never touches the strong one.
The shape of it
Submission, untrusted
The browser posts a claim. Track, faction, mode, lap times, position, drift count, the RNG seed and the full input log. None of it is believed.Validation, server
Cheap guards, then the expensive one: the server re-runs the same deterministic simulation from the submitted seed and input log. If the replay does not reproduce the run, it is rejected and logged.Ledger and settlement
Valid runs earn points from the same pure function the client used to preview them. At season end the standings are frozen, hashed into a Merkle tree, and the root is posted on chain.
Submission
You can race without signing in. The run simply is not credited. That ordering is on purpose, because asking somebody to sign a message before they have seen the game is a bad trade for them.
Signing in is one wallet signature over a plain-text nonce message. No password, no account record, no gas. Before the collection mints nobody owns a racer, so every run is recorded and marked unranked. After mint you race as a racer you own, and ownership is checked on chain at submission time.
The exact request and response shapes are on the Race API page.
Validation
The simulation is a pure function of the seed and the player inputs. Fixed 60Hz timestep, one seeded RNG for everything random, a Buy Button cycle derived from the tick counter, and player controls quantized to five bits before the simulation reads them. The simulation consumes exactly what it records, so a replayed log reproduces the run by construction rather than by luck.
const replayed = await deps.replay({
trackId: result.trackId,
faction: result.faction,
mode: result.mode,
seed: result.seed,
inputLog: result.inputLog,
});
if (Math.abs(replayed.totalMs - result.totalMs) > 120) reject("replay-mismatch");
if (replayed.position !== result.position) reject("replay-mismatch");
// Award on the replayed values, never the submitted ones.
const award = pointsFor({ ...result, ...replayed });Two details do the real work. The tolerance is 120ms, which covers the last-frame boundary and nothing else. And points are computed from the replay output, not from the submission, so a client that lies about its drift count inside the time tolerance still gets paid the truth.
Lap count is pinned to 3 by the server rather than read from the request, so a submission cannot shorten its own race.
What gets rejected and why
Cheap guards run first so obvious junk never reaches the simulator. A rejection always comes back with its reason, because a silent refusal reads as a broken game.
| Reason | Trigger | Why it exists |
|---|---|---|
| rate-limited | More than 6 submissions from one wallet in the trailing minute. | Nobody races six times a minute. The wall is cheap and it runs first. |
| seed-already-used | That wallet already submitted a run with this seed this season. | Replaying one good run forever is the easiest attack there is. |
| unknown-track | A trackId that is not one of the three. | No par time, no floor, nothing to check against. |
| below-record-floor | A total under the per-track floor: 58s Vault Alley, 40s The Trenches, 40s Night-Vision Swamp. | The AI's best over every faction and several seeds is 92.8s, 64.1s and 63.7s. Anything a third under that is a modified client, not a driver. |
| input-log-malformed | Characters outside the log alphabet, or a log over 200,000 characters. | The simulator should never be handed arbitrary strings. |
| input-rate-implausible | More than 40 recorded input events per second of race time. | A human hand has a ceiling. See the note under this table. |
| duration-mismatch | Total shorter than the sum of its own laps, or more than 3,000ms longer. | The clock starts before the line, so a small positive offset is correct and a negative one is arithmetic that did not happen. |
| replay-mismatch | The server's re-simulation finished more than 120ms away from the claim, or in a different position. | This is the real check. Everything above it is there to keep junk out of the simulator. |
Points
One implementation, imported by both sides, so the number the results screen shows and the number the server credits cannot drift apart.
Grand Prix
| Finish | Points | Label |
|---|---|---|
| P1 | 25 | Race win |
| P2 | 18 | P2 |
| P3 | 15 | P3 |
| P4 | 12 | P4 |
| P5 | 10 | P5 |
| P6 | 8 | P6 |
| P7+ | 0 | Outside the points |
Time Trial
| Against par | Points | Label |
|---|---|---|
| ≤ 92% | 25 | Course record pace |
| ≤ 100% | 18 | Under par |
| ≤ 108% | 12 | Par |
| ≤ 120% | 8 | Off pace |
| Slower | 4 | Finished |
Drift bonus
One point per ten completed drift boosts, capped at three. In Grand Prix it only applies if you scored in the first place, so a seventh place cannot drift its way into the points. The cap exists so style never outweighs the result.
Standings
A wallet is ranked on the sum of its best result per track. Grinding one easy track cannot out-score driving all three well. Ties break on the earlier submission, which rewards setting a pace rather than matching one.
- Only ranked runs count, so pre-mint test drivers appear nowhere in the table.
- The faction shown next to a wallet is the one that earned its best result, not the one it drives most.
- The faction championship averages points per racer rather than summing them, so a large faction cannot win on headcount.
- The standings endpoint returns the top 100 plus the faction table and the total run count.
The live table is at Race Control.
Season settlement
On chain, the season registry stores commitments and nothing else. Race results are computed off chain, because putting a physics simulation on an L2 would be an expensive way to be slower.
Daily
The server hashes each day's batch of race results and callscommitBatch(seasonId, batchHash). The log is append only, so anyone can check a published batch file against its on-chain hash later.Season end
The server builds a Merkle tree over the final standings and callspostSeasonRoot(seasonId, root). Roots are immutable once posted. A wrong root means posting a new season, never rewriting an old one.Forever after
Anyone callsverifyResult(seasonId, proof, leaf)to prove a placement against the posted root, without trusting our server. Worked example.
/** keccak256(abi.encode(tokenId, rank, points)) */
export function leafFor(row: StandingRow): Hex {
return keccak256(
encodeAbiParameters(
[{ type: "uint256" }, { type: "uint256" }, { type: "uint256" }],
[row.tokenId, BigInt(row.rank), BigInt(row.points)],
),
);
}Pairs are sorted before hashing, matching OpenZeppelin's MerkleProof, so proofs need no position bits. That encoding is part of the contract, not an implementation detail: if the two sides ever disagree, every proof fails and the failure looks like a data bug rather than an encoding bug.
Only standings backed by a real token can be settled, because the contract's leaf is keyed by token ID and a pre-mint test driver does not have one.
node --no-warnings rewards/dist/settle-season.js --season 1 --dry-runRead the standings it prints, then drop --dry-run. The script refuses to post a root twice, writes the proof file before broadcasting, and freezes the local standings only after the transaction confirms.
Winning a kart
Points are the season. A kart is the prize, and it works differently: win a Grand Prix and the treasury hands you a racer it already owns. Nothing is minted for it. The collection is capped at 3,000 by a constant in a contract that is already deployed, so a prize has to be bought off the floor before it can be won.
Race control decides the winner, because it is the only party that re-simulates the run, and signs an EIP-712 voucher naming you. You send the claim yourself and pay the gas. The site never holds your kart and never needs your key. The voucher expires in thirty minutes and its nonce is derived from the race rather than drawn at random, so asking twice about the same win produces the same nonce and the contract refuses the second claim.
Multiplayer wins pay the same way. The room server simulated the race so there is nothing to replay, but it holds no signing key: it reports the result to the site, which applies these same rules and puts the voucher where the results screen can collect it.
Trophies
Trophies mint to the racer's ERC-6551 token-bound account rather than the owner's wallet, so a champion stays a champion after it sells. That is the second rarity layer: a plain-looking kart with two season titles should out-price a bare epic.
The limit, stated plainly
Replay validation catches modified clients, edited timers, teleports and impossible lap times, because none of those survive a re-simulation. It does not catch a bot that plays the game legitimately and better than a human. There is no signal in the input log that separates a very good driver from a very good script, because at the level of a five-bit button stream there is nothing to separate.
That is acceptable for points and leaderboard placement. It is not, on its own, sufficient for karts, which have real value. So the prize layer does not rely on it alone: multiplayer races are simulated by the server rather than submitted, the pool is finite and every kart in it was paid for, and the two rate limits above bound what a patient script can extract per day no matter how many wallets it owns.
What none of that does is make solo farming impossible. A bot that drives well beats the built-in AI, and the honest description of the daily cap is that it decides how expensive the exposure is rather than removing it. Requiring a player to already own a racer would close it much harder, at the cost of shutting out the new players the pool exists to reach. That trade is open, and it is one switch.
Two smaller honesties. Rate limiting and single-use seeds are per wallet, and wallets are free, so a determined person with many addresses gets many attempts. And the standings rank on best result per track, so volume alone buys nothing.