Building Uppy: A Technical Deep Dive

by Alex Salerno

I built a silly little multiplayer browser game called Uppy — tap to keep the balloon airborne, the more players in your lobby the harder it gets, there's a bird trying to pop the balloon. The game is dumb on purpose. What's interesting is the stack underneath: there are no traditional servers anywhere. The entire backend is a Cloudflare Worker plus per-lobby Durable Objects plus D1 for persistence. It deploys with one wrangler deploy. Total infrastructure cost so far: free tier.

This post is the technical retrospective: why this stack, the math behind the cooldown that makes Uppy feel cooperative instead of competitive, and what I learned about running real-time multiplayer at the edge.

Why Build It

The honest answer is: I wanted to make a silly game and learn WebSockets at scale. That's it. Most tutorials of "real-time multiplayer on Cloudflare" stop at chat or a shared cursor; I wanted something with actual game state — physics, score, hazards — that has to stay consistent across every connected player at multiple ticks per second. If it works for a game with sub-100ms per-tick state sync across continents, it'll work for almost anything else I'd build on this platform later.

The "silly" part wasn't accidental either. The whole point of a learning project is that nobody's depending on it, so you can take design risks that would feel reckless in a paid product. Tap-a-balloon is one of those risks.

Design Goals

Four principles drove the calls that mattered:

  • Fun first. This is the load-bearing one. If a design choice would make Uppy more correct or more clever but less fun, the design choice loses. Every other principle below is downstream of this one.
  • Cooperative, not competitive. Every player should feel like part of the team. As the lobby grows, individual players should feel more dependent on strangers, not less. The cooldown formula (more on this below) is the mechanism, but the goal came first.
  • Lightweight onboarding, but a real one. The only ask is an email address — magic-link login, no passwords, no third-party providers. That single ask is the price of entry for everything that comes after: a persistent identity for the cooldown logic, anti-abuse protection against drive-by trolling, and a stable handle for the leaderboard. No ads, no installs, no paywalls, no "free trial" nonsense.
  • One thumb, mobile-first. The whole game can be played with one thumb. Landscape orientation is enforced because the playfield is wider than it is tall.

A few smaller principles supported those: no passwords (magic-link only), no installable client, no daemon to run, no third-party state service. Once the player's through the magic-link, joining a lobby is one tap.

What's Already Out There

I wasn't trying to replace any of these — Uppy exists in a deliberately weird genre — but it's worth grounding the design in adjacent work.

GameWhat it does wellWhy Uppy isn't that
agar.io / slither.ioOpen-lobby multiplayer with simple controlsCompetitive — Uppy is cooperative by construction
Cookie Clicker (multiplayer mods)Tap economy that scales with playersNo state shared across players — each one runs their own simulation
The Button (reddit, 2015)Forced cooperation through a shared resourceSingle global lobby, no game loop, ran once
Curiosity: What's Inside the Cube?Massively-multiplayer tap mechanic with collective goalOne-shot, single global game, no per-lobby state

The closest cousin in spirit is The Button — a shared cooperative tap mechanic with a real penalty for failure. Uppy borrows that energy and makes it a repeatable, per-lobby game with a satisfying short loop.

Architecture Overview

The whole thing runs on Cloudflare's edge platform. Five primary moving parts:

ClientsBrowser (plain JS)Browser (plain JS)Browser (plain JS)Edge (Cloudflare Worker)Routing + Asset serving + Lobby gatewayPer-Lobby StateLobbyDO (game tick)LobbyDO (game tick)RegistryDO (matchmaking)Persistence + AuthD1 (users, scores)CF Email (magic-link login)PostHog (analytics)

The Worker handles routing, serves static assets, and acts as a gateway that hands each incoming connection to the right Durable Object. Each lobby is its own DO with its own consistent state, its own game tick, and its own WebSocket fan-out. The DO is the source of truth — there is no Redis, no Pub/Sub, no separate state service.

The whole thing fits the Cloudflare free tier comfortably. A single Worker, a handful of Durable Objects per active lobby (they spin down when idle), a small D1 database, and the Email Service for magic-link login.

The Mental Model

Lobby (a single Durable Object)
├── Connected players (via WebSocket, fan-out built into the DO)
│   ├── Per-player cooldown
│   └── Per-player palette + nickname + score contribution
├── Balloon physics (position, velocity, score)
├── Bird hazard (spawns randomly, tries to pop the balloon)
└── Game state (waiting | playing | ended)

Joining a game means: client opens a WebSocket → Worker resolves the lobby code to the matching DO → DO accepts the socket and starts broadcasting tick snapshots. Every tick (15 Hz) the DO computes the new state and pushes a compact binary snapshot to every connected client. Clicks come back as small messages, get validated against the player's cooldown, and either apply or get rejected.

Two things to call out:

  • The DO is single-threaded and request-serialized. There is no concurrent-access pattern to defend against. You don't need locks, you don't need transactions, you don't need consensus — the DO is the sequencer. Every player's tap goes through the same point.
  • WebSocket fan-out is built in. No glue code, no separate state service. The DO holds the connection list, computes the snapshot, and broadcasts. This is the killer feature for multiplayer.

Key Design Decisions

Why Cloudflare's edge platform

I wanted to learn this platform properly. A real-time multiplayer game is one of the harder things you can build on it, because the assumptions Cloudflare makes about Workers (short-lived, request-response, no shared state) break completely once you need long-lived connections and stateful game ticks. Durable Objects fill that gap, and they fill it well — but you have to design around them.

The constraints turned out to be the right ones. Forcing every piece of state to live in either a Durable Object or D1 made the architecture obvious in a way that "stand up a server and put Redis next to it" doesn't. There's no fourth thing. If state needs to be hot, it goes in the DO. If it needs to survive a DO eviction, it goes in D1. That's the entire decision tree.

The cooldown formula

This is the most interesting design decision in Uppy, and the one I want to spend the most time on.

Every player has a per-tap cooldown. After you tap, you can't tap again for some number of seconds. The number depends on how many players are in your lobby. The formula:

cooldown_ms(N) = min(15000, round(1500 + 2032 · log₂(max(1, N))))

Three things to notice. At N=1 (solo), the cooldown is 1.5 seconds, which feels snappy. The constant 2032 was picked empirically — playtest, tweak, playtest, tweak — until the curve felt right at small and medium lobbies. And there's a hard cap at 15 seconds, so no individual player ever has to wait longer than that regardless of how big the lobby gets.

15s hard capcap at N=1000369121518Cooldown (seconds)0255075100125150175200Players in lobby
Per-player cooldown grows logarithmically with lobby size — fast climb at the start, slowing as the lobby grows, then flat at the 15-second cap once the lobby exceeds ~100 players.

The first thing to notice is the shape: a fast climb at the start (every new player matters a lot when you're 2-vs-3), tapering as the lobby grows (the 50th player adds much less to the cooldown than the 5th), then a hard plateau once the cap kicks in around 100 players. That's the logarithmic formula doing its job — each doubling of players adds the same ~2 seconds to the cooldown, which on a linear x-axis looks like the curve above. At small lobby sizes (2-8 people, the sweet spot for actual play sessions) the cooldown grows quickly enough that players feel the pressure mounting; the floor (1.5 seconds) and ceiling (15 seconds) keep both ends of the range playable.

The second graph is the one that actually drives the gameplay feel. If everyone in the lobby is tapping at the maximum allowed rate, how many taps per second can the lobby collectively produce?

cap inflection (N=100)02468101214Max clicks per second0255075100125150175200Players in lobby
Max collective tap rate (N / cooldown_seconds) vs. lobby size. Below the cap (~100 players) the curve grows sub-linearly — the cooldown is "winning" against the player count. Above the cap, the cooldown stops rising and tap rate grows linearly at N/15.

This is where the cooperative-feel design pays off. The pre-cap region — which is where almost all real lobbies live — is sub-linear. Adding more players helps less than you'd expect. Going from 8 players to 16 doesn't double your tap rate; it adds maybe 60%. The team isn't getting easier to be on, it's getting harder to keep coordinated. Players who panic-tap waste their cooldown; players who pace themselves contribute more per tap.

The cap at N≈100 is where it flips. Past 100 players the cooldown stops growing, so the lobby's collective tap rate becomes linear in player count. In practice no Uppy lobby has ever needed to live in that regime — it's there as a safety net so the game doesn't become unplayable in some hypothetical viral moment, not as a tuning knob anyone touches.

The whole curve is two parameters and one constant. There's no special-casing, no rubber-banding, no per-player handicap. The game balances itself.

Per-lobby Durable Object isolation

Each lobby is one Durable Object. They have nothing to do with each other. A crash, a hot-loop, a memory spike in one lobby has zero effect on the others. The blast radius is literally one game.

This is the kind of isolation that on traditional infrastructure you'd build with separate processes or pods, and it'd be expensive enough that you'd think twice about doing it per-lobby. On Cloudflare it's free — the DO model assumes this is how you'd use it. You don't have to design around shared state because there isn't any.

Plain JavaScript on the client

No framework, no build step, no bundler. The client is one main.js module, served as-is from the Worker's assets binding. The whole client-side codebase is ~800 lines.

This was a deliberate choice. The game doesn't need state management beyond a couple of lets and a snapshot ring buffer; React would have been net-negative complexity. The performance is great because there's no virtual-DOM layer between the WebSocket message and the canvas redraw — the snapshot decoder writes directly into the state, and the next animation frame picks it up.

PostHog feedback survey on game-over

After every round, players see a one-question feedback prompt: "How was that game? (1–5)" with an optional text field. The trigger is the existing balloon_down analytics event we already fire on round end. PostHog handles the survey display itself; the client is unchanged.

The frequency cap is 7 days per user — heavy players don't get prompted every game, occasional players get the prompt when it matters. The response volume so far is tiny but the responses I've gotten have been disproportionately useful, because they arrive within ten seconds of the moment that produced them.

The Bird

The bird is the hazard. It enters the playfield, moves around for a bit, and tries to pop the balloon by colliding with it. The randomness it adds is the point — without it, the game would just be a tap-rate-vs-gravity calculation. With it, players have to think about where the balloon will be in the next few seconds, not just whether it'll be high enough.

The bird is also the seam where the game gets more interesting. Right now there's one bird with one behavior. Planned: power-ups (slow time, double-strength taps, balloon shield), modifiers (gravity bursts, wind), and probably a porcupine next as a second hazard with a different movement pattern. Each new entity is a new physics interaction without changing the core loop.

If you're an artist who likes weird co-op games and wouldn't mind replacing my programmer art, the door is open. The current visuals are functional, not delightful.

Implementation Notes

A handful of concrete choices worth recording:

  • Language: TypeScript on the Worker, plain JS on the client. Server-side type safety where state correctness matters, client-side simplicity where it doesn't.
  • Binary snapshot protocol over WebSocket. Each per-tick snapshot is a fixed-size binary buffer (header + balloon state + bird state + variable-length ripple list). JSON would have been simpler but the snapshot is sent 15 times per second to every connected client; bytes matter.
  • Optimistic local ripples + server-side validation. When you tap, you see a ripple on the canvas immediately — the local client doesn't wait for the server. If the server rejects the tap (cooldown not yet expired), you see a small "X" mark on the canvas instead of a ripple. The feeling is responsiveness; the behavior is correctness.
  • D1 for everything that survives a DO eviction. Users, profiles, scores, nicknames. The DO holds the active game state; D1 holds anything that needs to outlive the game.
  • Cloudflare Email Service for magic-link login. No third-party email provider, no SMTP config, no domain verification dance — Cloudflare handles it inside the same dashboard as everything else.
  • Cloudflare Access on dev.uppygame.com. The dev environment is gated behind an Access policy. Production is wide open.
  • No client build step. Plain ES modules served from the Worker's asset binding. The deploy pipeline is one wrangler deploy.

The deliberate non-choice: no framework on the client, no shared state library on the server. Both would have looked productive in the short term and become a tax forever after.

What I Learned

  • Walking in and balancing everything was the hardest part. A player joining a lobby mid-round is the single most complicated thing the server does. The new player needs a palette, a starting cooldown that doesn't unbalance the in-progress game, a position in the player list, an introduction packet that brings them up to speed on the current physics state, and a fair share of the per-player contribution without disrupting players who've been there since the round started. None of this is hard in isolation; all of it has to happen in the right order and stay correct if the player drops their connection a tick later. Reconnect handling alone took longer than the entire physics layer.
  • Scaling the lobbies was easier than expected. This is the upside surprise of building on Durable Objects. Adding more lobbies is just adding more DOs; there's no shared state to contend over, no resource pool to size, no horizontal-scale design to do. Each lobby is its own one-room universe. The model just works at any number of concurrent games, which is the kind of thing you'd spend weeks engineering on traditional infrastructure.
  • Durable Objects are perfect for multiplayer state. Real-time sync across clients without needing Redis or Pub/Sub. The DO is the source of truth and the fan-out is built in. This is the single most useful primitive Cloudflare has added since Workers themselves.
  • Playtesting with strangers is a different beast. Real human latency, real weird inputs, real "I clicked, nothing happened for two seconds, then five things happened at once" reports. Logs you can't reproduce in dev. The PostHog survey turned out to be the single most useful diagnostic tool for the early period because it gave me unprompted feedback exactly when something memorable had just happened.
  • The free tier goes further than you'd expect. A single Worker, a handful of Durable Objects per active lobby, a D1 database, the Email Service. None of it costs anything at the volume Uppy runs at, and the headroom before it would is enormous.
  • Simple gameplay is harder than it looks. The cooldown formula took a half-dozen passes before it felt right. Every constant mattered. Every floor and ceiling mattered. The graphs above hide the iteration that produced them.

The Road Ahead

Four things on the short list:

  1. Power-ups + modifiers. Slow time, double-strength taps, balloon shields. Gravity bursts, wind. Each one is a new physics interaction layered on top of the existing loop; none of them change the core mechanic.
  2. A porcupine as a second hazard with a different movement pattern. The bird darts; the porcupine drifts.
  3. An artist. The current visuals are programmer art. The game would be meaningfully more delightful with real character design. If that's you, get in touch.
  4. Native mobile apps for iOS and Android. The web build already feels good on phones, but a native wrapper is the right next step for the audience this is built for (more on that below).

Beyond those, nothing concrete. I'm letting playtesting drive priorities rather than guessing.

Who It's For

Anyone, honestly. The shapes of group I had in mind while building it:

  • Streamers and their chats. A lobby code is five characters; chat can pile in fast.
  • Subreddit and Discord communities. A short-session co-op game is a low-effort way to do something together that isn't watching a video or arguing.
  • Teams who need a five-minute palate cleanser. Stand-up's running over, retro's getting heated, someone needs a break — Uppy is one tab and ten seconds away.

If you fit any of those, give it a try and bring people. Lobbies feel different at 2 players, at 8 players, and at 20.

Closing

Uppy started as a learning project for Cloudflare's edge platform and a silly excuse to ship a game. Both goals turned out to be load-bearing. Picking a real-time multiplayer game as the learning vehicle forced every weak spot in my understanding of Durable Objects, WebSockets, and edge-side state management to surface — and the silliness gave me the permission slip to take design risks I wouldn't have taken on a serious product.

The cooldown formula is the part of the game I'm proudest of, because it does in two parameters what a more conventional design would do with rules, exceptions, and rubber-banding. Logarithmic scaling, hard cap, no special cases. The math balances the game.

Play it at uppygame.com. It's silly. It works on phones. There's a feedback prompt after every round — that's the fastest way to tell me what's broken or what would make it better.

More articles

How to Add a Health Check to a Raid Workflow

Use the Raid `Wait` task to block on HTTP endpoints or TCP ports until a service is healthy — and pair it with `Group` for retry semantics on flaky deps.

Read more

How to Add a raid.yaml to an Existing Repo

Commit a raid.yaml to any repo so the Raid CLI can run its commands, environments, and install steps — and merge them with the team profile automatically.

Read more