{"slug": "a-whole-replay-in-a-url-how-flappycar-s-ghost-links-work-without-a-backend", "title": "A whole replay in a URL: how Flappycar's ghost links work without a backend server", "summary": "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.", "body_md": "I built a small browser game, Flappycar, in a few hours with an AI coding agent. It is one\n\nbutton: a tap fires a sports car's cold-gas thrusters, and holding the tap kickflips it.\n\nEveryone gets the same course each day.\n\nThe part I want to write about is the share. When you send a friend your score, the link carries\n\nyour entire run. Their game replays it as a translucent ghost car beside them, with your name on\n\nit, and they race it. There is no backend server behind this, no accounts and no database. The game is a\n\nstatic site, and everything a friend needs travels in the URL.\n\n*One tap fires the thrusters. The course gets darker, and faster, the further you get.*\n\nThe usual way to let people race each other is an account system, a database of runs and an API\n\nto fetch them. I didn't want to operate any of that for a toy. Flappycar is about 4,900 lines of\n\nplain JavaScript with no dependencies, served as static files from a free hosting tier. So the\n\nquestion became: what is the smallest thing that, sent in a link, lets someone else's browser\n\nrebuild my run exactly?\n\nThe answer turned out to be very small, because two design decisions do most of the work.\n\n**The physics runs at a fixed step.** The game simulates at 120 steps a second regardless of the\n\nscreen's refresh rate. Each animation frame adds real elapsed time to an accumulator and spends it\n\nin whole steps:\n\n```\nacc += dt;\nwhile (acc >= STEP) {\n  update(STEP);   // STEP = 1 / 120\n  acc -= STEP;\n}\n```\n\nGiven the same starting state and the same inputs on the same step numbers, a fixed-step\n\nsimulation produces the same run on every machine.\n\n**The course is a function of a seed.** The daily course is generated from the date. Daily #1\n\nwas 16 October 2026; the day number is scrambled by a small integer hash into a 32-bit seed,\n\nand that seed drives a tiny pseudo-random generator (mulberry32). The obstacle spawner draws\n\nonly from that generator. Anything purely visual, like smoke and sparks, uses other randomness, so\n\nit can never shift the course.\n\nPut those together and a run is fully described by three things: which course it was on, the\n\nstep numbers where the player tapped or started a kickflip, and the step it crashed on.\n\nReplaying a friend's run means stepping a second car in lockstep with yours and firing its\n\nthrusters on their recorded steps:\n\n```\nwhile (run.idx < ev.length && ev[run.idx][0] === run.step) {\n  if (ev[run.idx][1] === 1) startFlip(run.car);\n  else fireThrusters(run.car);\n  run.idx += 1;\n}\nstepCar(run.car, STEP, speed);\nrun.step += 1;\n```\n\nNo positions, no velocities, no frames. Just the taps.\n\nThe encoding is a list of unsigned varints, then base64url so it survives being pasted into a\n\nchat. The header is the format version, whether it is a daily or a random course, the day or\n\nseed, the score, the step the run died on and the number of events. Each event is stored as the\n\ngap since the previous one, doubled, plus one bit for \"tap\" or \"kickflip\":\n\n```\nexport function encodeGhost({ day = 0, seed = 0, score, deathStep, events }) {\n  const bytes = [VERSION];\n  const endless = !day;\n  pushVarint(bytes, endless ? 1 : 0);\n  pushVarint(bytes, endless ? seed >>> 0 : day);\n  pushVarint(bytes, score);\n  pushVarint(bytes, deathStep);\n  pushVarint(bytes, events.length);\n  let prev = 0;\n  for (const [step, kind] of events) {\n    pushVarint(bytes, (step - prev) * 2 + (kind ? 1 : 0));\n    prev = step;\n  }\n  return toBase64Url(bytes);\n}\n```\n\nTaps come roughly every 0.7 seconds, about 86 steps apart, so each one costs about two bytes.\n\nTwo runs on Daily #2:\n\n| Run | Taps | Bytes | Characters in the link | \n|---|---|---|---|\n| 44 seconds, 26 obstacles | 61 | 127 | 170 | \n| 92 seconds, 60 obstacles | 128 | 263 | 351 | \n\nA good run fits comfortably in a link a chat app will preview and nobody will notice. The rest of\n\nthe link is plain query parameters: the sender's name, their car's colour and the day.\n\n*What a friend sees when they open your link, and what they see when they beat it.*\n\nOnce ghosts worked, the obvious next request was a leaderboard, which normally means a backend server\n\nagain. Instead, each link carries a short crew list: every person whose ghost the link has passed\n\nthrough, with their score, up to five. When you share after racing a link, your sender is added\n\nto it. A group chat builds up standings as the link goes round, and the game shows them on the\n\nresult panel and in the share text.\n\n*Standings from a link that has been round four people. The URL is the database.*\n\nThe trust model is honest about what it is: anyone can edit a URL. Among friends in a chat,\n\nthat is fine. It is not a global high-score table, and it doesn't pretend to be.\n\n**Tuning changes break old links, silently.** A replay is only exact if the physics and the course\n\nare the same as when it was recorded. Widen a gap or change a speed curve and an old ghost flies\n\nstraight through a tower. That is why the first byte is a format version, bumped for every change\n\nthat touches the course, the physics or the scoring. The header layout never changes, though, so\n\nan older link still opens on its day with the sender's name and the score to beat. It just races\n\nwithout the ghost car, instead of drawing one flying through scenery that has moved.\n\n**Links get damaged in transit.** A friend's link refused to load the day after launch. The version byte\n\nwas fine. Decoding it showed that three characters had changed from `A` to `B` somewhere between\n\nthe sender's phone and the recipient's. In this encoding, that turned three 80-step gaps between\n\ntaps into 336-step gaps. Correcting those three characters replayed the run to its exact score.\n\nThe format has no checksum, so the game can't tell a damaged link from garbage. Anything that\n\ntravels through chat apps should carry one.\n\n**Analytics can't see the clever part.** The hosting's privacy-friendly analytics records the path\n\nand referrer, but drops the query string, which is exactly where the ghost lives. So the paths\n\nthemselves carry the signal: every challenge link lands on `/c/`, and the number of `/c/` visits\n\ncompared with the number of links sent is a direct read of whether the game spreads.\n\nThe thing I didn't expect: the agent was fastest at the parts I would have procrastinated on,\n\nlike the share card, the emoji result strip and this replay encoding. It needed the most steering\n\non feel: how high a tap lifts the car, how wide the gaps are, how the crash lands. Those took\n\nplaying, not prompting.\n\nWhat made it trustworthy was making it build its own checks. A headless soak flies the game's\n\nautopilot through a hundred courses at a time to prove every gap is passable. The GIFs and screenshots in\n\nthis post were filmed the same way, by driving the real game frame by frame in a headless browser\n\nwith the autopilot flying. They are the game, not mock-ups.\n\nFlappycar is at [flappycar.com](https://flappycar.com). It is free, works on a phone, has no\n\nsign-up and installs to your home screen if you want it to. Today's course is the same for\n\neveryone. If you beat a friend's ghost, the game makes a link with yours in it; send it back.\n\n*A friend's ghost goes down, and the run carries on past its score.*\n\nTell me where you crashed.", "url": "https://wpnews.pro/news/a-whole-replay-in-a-url-how-flappycar-s-ghost-links-work-without-a-backend", "canonical_source": "https://dev.to/ninjamonkeys/a-whole-replay-in-a-url-how-flappycars-ghost-links-work-without-a-backend-server-4l08", "published_at": "2026-10-19 10:37:06+00:00", "updated_at": "2026-10-19 10:44:37.981046+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "generative-ai"], "entities": ["Flappycar"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/a-whole-replay-in-a-url-how-flappycar-s-ghost-links-work-without-a-backend", "markdown": "https://wpnews.pro/news/a-whole-replay-in-a-url-how-flappycar-s-ghost-links-work-without-a-backend.md", "text": "https://wpnews.pro/news/a-whole-replay-in-a-url-how-flappycar-s-ghost-links-work-without-a-backend.txt", "jsonld": "https://wpnews.pro/news/a-whole-replay-in-a-url-how-flappycar-s-ghost-links-work-without-a-backend.jsonld"}}