A whole replay in a URL: how Flappycar's ghost links work without a backend server A developer built Flappycar, a browser game created in a few hours with an AI coding agent, that shares entire runs as self-contained URLs with no backend server, accounts or database. The game runs physics at a fixed 120 steps per second and generates each daily course from a date-derived seed, so a run is fully described by the course, the step numbers of taps and kickflips, and the crash step; these are encoded as varints and base64url, with a 92-second run fitting in about 351 characters. I built a small browser game, Flappycar, in a few hours with an AI coding agent. It is one button: a tap fires a sports car's cold-gas thrusters, and holding the tap kickflips it. Everyone gets the same course each day. The part I want to write about is the share. When you send a friend your score, the link carries your entire run. Their game replays it as a translucent ghost car beside them, with your name on it, and they race it. There is no backend server behind this, no accounts and no database. The game is a static site, and everything a friend needs travels in the URL. One tap fires the thrusters. The course gets darker, and faster, the further you get. The usual way to let people race each other is an account system, a database of runs and an API to fetch them. I didn't want to operate any of that for a toy. Flappycar is about 4,900 lines of plain JavaScript with no dependencies, served as static files from a free hosting tier. So the question became: what is the smallest thing that, sent in a link, lets someone else's browser rebuild my run exactly? The answer turned out to be very small, because two design decisions do most of the work. The physics runs at a fixed step. The game simulates at 120 steps a second regardless of the screen's refresh rate. Each animation frame adds real elapsed time to an accumulator and spends it in whole steps: acc += dt; while acc = STEP { update STEP ; // STEP = 1 / 120 acc -= STEP; } Given the same starting state and the same inputs on the same step numbers, a fixed-step simulation produces the same run on every machine. The course is a function of a seed. The daily course is generated from the date. Daily 1 was 16 October 2026; the day number is scrambled by a small integer hash into a 32-bit seed, and that seed drives a tiny pseudo-random generator mulberry32 . The obstacle spawner draws only from that generator. Anything purely visual, like smoke and sparks, uses other randomness, so it can never shift the course. Put those together and a run is fully described by three things: which course it was on, the step numbers where the player tapped or started a kickflip, and the step it crashed on. Replaying a friend's run means stepping a second car in lockstep with yours and firing its thrusters on their recorded steps: while run.idx < ev.length && ev run.idx 0 === run.step { if ev run.idx 1 === 1 startFlip run.car ; else fireThrusters run.car ; run.idx += 1; } stepCar run.car, STEP, speed ; run.step += 1; No positions, no velocities, no frames. Just the taps. The encoding is a list of unsigned varints, then base64url so it survives being pasted into a chat. The header is the format version, whether it is a daily or a random course, the day or seed, the score, the step the run died on and the number of events. Each event is stored as the gap since the previous one, doubled, plus one bit for "tap" or "kickflip": export function encodeGhost { day = 0, seed = 0, score, deathStep, events } { const bytes = VERSION ; const endless = day; pushVarint bytes, endless ? 1 : 0 ; pushVarint bytes, endless ? seed 0 : day ; pushVarint bytes, score ; pushVarint bytes, deathStep ; pushVarint bytes, events.length ; let prev = 0; for const step, kind of events { pushVarint bytes, step - prev 2 + kind ? 1 : 0 ; prev = step; } return toBase64Url bytes ; } Taps come roughly every 0.7 seconds, about 86 steps apart, so each one costs about two bytes. Two runs on Daily 2: | Run | Taps | Bytes | Characters in the link | |---|---|---|---| | 44 seconds, 26 obstacles | 61 | 127 | 170 | | 92 seconds, 60 obstacles | 128 | 263 | 351 | A good run fits comfortably in a link a chat app will preview and nobody will notice. The rest of the link is plain query parameters: the sender's name, their car's colour and the day. What a friend sees when they open your link, and what they see when they beat it. Once ghosts worked, the obvious next request was a leaderboard, which normally means a backend server again. Instead, each link carries a short crew list: every person whose ghost the link has passed through, with their score, up to five. When you share after racing a link, your sender is added to it. A group chat builds up standings as the link goes round, and the game shows them on the result panel and in the share text. Standings from a link that has been round four people. The URL is the database. The trust model is honest about what it is: anyone can edit a URL. Among friends in a chat, that is fine. It is not a global high-score table, and it doesn't pretend to be. Tuning changes break old links, silently. A replay is only exact if the physics and the course are the same as when it was recorded. Widen a gap or change a speed curve and an old ghost flies straight through a tower. That is why the first byte is a format version, bumped for every change that touches the course, the physics or the scoring. The header layout never changes, though, so an older link still opens on its day with the sender's name and the score to beat. It just races without the ghost car, instead of drawing one flying through scenery that has moved. Links get damaged in transit. A friend's link refused to load the day after launch. The version byte was fine. Decoding it showed that three characters had changed from A to B somewhere between the sender's phone and the recipient's. In this encoding, that turned three 80-step gaps between taps into 336-step gaps. Correcting those three characters replayed the run to its exact score. The format has no checksum, so the game can't tell a damaged link from garbage. Anything that travels through chat apps should carry one. Analytics can't see the clever part. The hosting's privacy-friendly analytics records the path and referrer, but drops the query string, which is exactly where the ghost lives. So the paths themselves carry the signal: every challenge link lands on /c/ , and the number of /c/ visits compared with the number of links sent is a direct read of whether the game spreads. The thing I didn't expect: the agent was fastest at the parts I would have procrastinated on, like the share card, the emoji result strip and this replay encoding. It needed the most steering on feel: how high a tap lifts the car, how wide the gaps are, how the crash lands. Those took playing, not prompting. What made it trustworthy was making it build its own checks. A headless soak flies the game's autopilot through a hundred courses at a time to prove every gap is passable. The GIFs and screenshots in this post were filmed the same way, by driving the real game frame by frame in a headless browser with the autopilot flying. They are the game, not mock-ups. Flappycar is at flappycar.com https://flappycar.com . It is free, works on a phone, has no sign-up and installs to your home screen if you want it to. Today's course is the same for everyone. If you beat a friend's ghost, the game makes a link with yours in it; send it back. A friend's ghost goes down, and the run carries on past its score. Tell me where you crashed.