TangYi Studio

Xianzi Insights

A Party on the Edge: One Person, One AI, Shipping a Party Game to Cloudflare Workers

林政賢 ·

This is not a "the AI wrote me a hello world" story. This is a shipping app: 1,428 cards, 22 pages, 17 database tables, real-time multiplayer, in-app subscriptions, approved by App Store review. What follows is whether this combination actually works, and the traps nobody warns you about first.

Let me establish the scale, or none of what follows carries any weight.

PURPLE TIPSY NIGHT X · CURRENT STATE

1,428question cards across four series, three difficulty levels and seven limited decks
39ending cards with six rarity tiers and a gyroscope-driven 3D effect
22front-end pages, 16 of them loaded on demand
17business tables and 15 migrations
2–10players in a live room, with chat, scoring, matchmaking, reporting and blocking

The whole thing runs on Cloudflare Workers: one Worker is both the API and the static site, data lives in D1, images in R2, real-time messages go through Ably, auth through better-auth, subscriptions through RevenueCat. The iOS app is a Capacitor shell around the same website — which means changing a screen, a rule, a card or the database needs no new build.

Nearly all of the development happened as a conversation between me and Claude: I describe what I want, look at it on a real device, point out what is wrong; it reads the code, finds the cause, fixes, deploys, verifies. This piece is not here to convince you that this is magic. What I want to describe is which parts genuinely saved enormous time, and which parts it gets confidently wrong.

Why Cloudflare

Not because it is cheap, though it is. Because one Worker is the whole thing.

The traditional split is front-end hosting, an API server, a database, a CDN and file storage — five places, each configured separately, each billed separately, each with its own deploy process. On Workers those are a few binding lines in the same wrangler.json:

"d1_databases":  [{ "binding": "DB",     "database_name": "ppnx-db" }],
"r2_buckets":    [{ "binding": "MEDIA",  "bucket_name": "ppnx-media" }],
"assets":        { "directory": "./dist/client", "binding": "ASSETS" }

Then npm run deploy, and everything ships at once. For "one person builds a whole product" that matters far more than the money — there is only one mental model to maintain. And when your collaborator is an AI, it matters more still: the entire system can be read in one pass, so it has a chance of actually understanding what you are building, instead of guessing what state the other four services are in.

Why Claude

Not "it can write code" — that bar was cleared a while ago. What actually separates it is that it goes and verifies instead of answering from memory.

One real example from this week. A user reported: "everything in the room is slow, the buttons do not respond."

The from-memory answer is "the network is slow, add a cache." What actually happened: measure the round trip from Taiwan to the Worker first (0.43–0.70 seconds, genuinely slow, because Taiwanese routes land in San Jose while the database sits in Asia-Pacific), ship an optimisation — and then the user said still slow.

A slow network does not make buttons stop responding.

That sentence overturned the whole direction. The second round measured something entirely different: DOM mutations while the page sits idle.

SAME SCREEN, NO INPUT, DOM MUTATIONS IN 10 SECONDS

78before the fix — repainting eight times a second while doing nothing
0after the fix

The cause was a once-a-second timer (checking whether a chat bubble had expired) that ran unconditionally, and every tick re-rendered the entire room component. That clock is only needed during the six seconds a bubble is on screen; the rest of the time it is pure waste.

That bug had been there a long time. Finding it took no cleverer guess — it took measuring something else. And "measure before you fix" is a discipline the AI keeps far better than a person: it does not skip the step because it already thinks it knows the answer.

Four Cloudflare traps nobody warns you about

This is the part of the article worth keeping. Every one of these cost me at least half a day, and every one of them looks like something else entirely at first glance.

01A missing JS file returns 200, not 404

Symptom: after a deploy, users who already have the app open break when they navigate to some page, with an error message that explains nothing.

Filenames under /assets/ carry a content hash and change on every deploy. An old tab requests the old filename, and not_found_handling: "single-page-application" makes any path that is not found return the homepage HTML — including .js. So the browser receives "HTTP 200 plus text/html" and tries to parse it as a JS module, which throws a MIME error.

Fix: when a dynamic import fails, reload the page once (the reload fetches new HTML and new filenames), and remember it in sessionStorage to avoid an infinite loop. This is a general SPA-plus-edge-hosting problem, not Cloudflare-specific, but this setting turns it from an obvious 404 into a bizarre MIME error.

02Zone cache settings override the headers your Worker returns

Symptom: you set Cache-Control in the Worker, change the content, and it takes hours to appear.

The zone-level Browser Cache TTL in the Cloudflare dashboard (four hours by default) takes precedence over the Cache-Control your Worker returns. There is a symmetric misunderstanding on the other side: a Worker response is not edge-cached by default. You think it cached it for you; it did not — you have to use caches.default yourself.

Fix: to bypass the zone TTL, add a changing parameter to the URL (a minute-level ?v=, for instance). To get edge caching, write caches.default explicitly. Neither happens on its own.

03OAuth callbacks intercepted by the static asset layer

Symptom: the Google login callback returns 404 and the Apple one returns 405. And when you test that URL with curl, it works perfectly.

The static asset layer intercepts navigation requests, and the SPA fallback means the callback never reaches the Worker at all. curl cannot reproduce it because it does not send a navigation request — you need Sec-Fetch-Mode: navigate for the failure to show up.

Fix: put those paths in run_worker_first. And if you mount a Worker on a sub-path, remember to set html_handling: "none", or visitors get a 307 back to the main site's homepage.

04The wrangler login has narrower permissions than you assume

Symptom: certain wrangler commands always return Unauthorized [code: 2036], no matter how you rewrite them.

The OAuth token you get from wrangler login has no DNS write permission and no Email permission. So "create a CNAME for a Pages custom subdomain" or "enable Email Sending" will always fail, and it has nothing to do with whether your command is correct.

Fix: either create an API token in the dashboard with the right permissions, or route around it — I moved DNS to a Worker path mount and sending mail to a third-party service already in use. Confirm it is a permissions problem before spending time debugging the command; this one saved the most time.

The geography of the edge

Workers run on edge nodes worldwide, which sounds fast everywhere. But your database is in exactly one place.

Measured from a machine in Taiwan to our API, one round trip is 0.43 to 0.70 seconds. The reason is that Cloudflare routes Taiwanese traffic to San Jose (colo=SJC) while D1 sits in Asia-Pacific. The request crosses the Pacific to reach the Worker, and the Worker crosses back to query the database.

Diagnosing it is simple: wrangler tail gives you both cpuTime and wallTime. Six milliseconds of CPU against 470 milliseconds of wall clock proves your code is not the slow part.

Once you know that, only two design rules remain:

But optimistic updates will bite you

This is my favourite bug of the week, because it is a textbook case of fixing one problem and manufacturing another.

After optimistic updates shipped, users reported the screen "jumping around": drawing a card would flash back to the deck menu before landing on the card, and submitting an answer would step backwards before moving forward.

The cause is that polling runs independently. A request that was sent before your write comes back carrying stale data; applying it directly pushes the screen you just updated back to where it was, and the next poll moves it forward again. You think you are fixing latency; what you are actually manufacturing is flicker.

The solution needs no timestamps and does not care about clock skew — use a monotonically increasing counter to decide which side is newer:

function mergeRoom(prev, incoming){
  const pT = prev.turn_count, iT = incoming.turn_count;
  const pC = prev.current_turn, iC = incoming.current_turn;
  if (iC > pC || iT > pT) return incoming;   // server is newer, take it wholesale
  if (iC < pC || iT < pT) return prev;       // poll is behind, discard it wholesale
  // same turn: the server has not received our action yet, keep just those fields
  if (prev.last_pick && !incoming.last_pick) return { ...incoming,
        last_pick: prev.last_pick, last_by: prev.last_by };
  return incoming;
}

As long as your state contains one number that only moves forward — a turn count, a version, a sequence number — this problem has a very cheap solution. Optimistic updates without a reconciliation strategy simply trade latency for flicker.

Where Claude gets it wrong

This section is for anyone planning to work this way. It is not omnipotent, and the ways it fails have a pattern.

1. It will confidently tell you it is fixed

The most common failure: change the code, deploy, then look at a stale cache in its own browser or at the local build output, and report "fixed." That happened more than once on this project.

The only discipline that works is verifying the file actually being served in production: fetch the deployed chunk with curl and grep for a key string. And confirm the grep really matched — minification removes whitespace, so a search string containing a space finds nothing, and you get a false alarm that looks exactly like "it did not deploy."

2. It will invent a handsome reason for its own choices

Ask "why did you do it this way" and it will almost always produce something that sounds thoroughly professional — even when the real reason is "that is how the previous step happened to be written." This is especially dangerous in a README or technical document, because other people read those words as design decisions.

My practice: run an independent adversarial audit before publishing anything that matters — open a clean conversation, give it only the code, ask it to find faults, and do not give it the original explanation.

3. It needs you to be the one who says "still wrong"

In that "everything is slow" example, the first optimisation was correct, the measurement was real, and the direction was wrong. What put it back on track was not a better prompt. It was the words "still slow."

This is probably the most useful sentence in the whole piece: your value is not whether you can write that code, but that you know it is not done yet. Test on a real device, describe concrete symptoms, refuse to accept "that should work now" — nothing has replaced you at that yet.


So, can this be copied

Yes, but pick the right kind of project.

Where this combination is strongest is a project where one person is responsible for all of it: no split between front-end, back-end and ops, no cross-team communication cost, one command from editing a line to being live. Workers compress the infrastructure to something one person can carry; Claude compresses the cost of "read thirty thousand lines and then change one correctly." Both have to hold at once for it to mean anything; either one alone is not enough.

Conversely, if your system is already spread across five clouds, three teams and two deploy pipelines, the bottleneck is not writing code, and this combination will not help much.

As for "will AI replace engineers" — from these few weeks, what it replaces is reading documentation, writing boilerplate, and tracking who broke what. What is left for people is deciding what is worth doing, and looking at a real device and saying "this is wrong." The weight of that second part has only increased.

About this piece: every number here was measured, not estimated. The 0.43–0.70 seconds is a real measurement from a machine in Taiwan against the live API; 78 to 0 is the DOM mutation count on the same idle screen over ten seconds; the card and table counts come straight from the database. All four Cloudflare traps include the cause and the fix, because only saying "there are landmines" is no use to the reader.

Purple Tipsy Night X is TangYi Studio's 16+ party quiz card app. The studio's open-source projects are at github.com/tangyistudio.

PLAIN-LANGUAGE VERSION

Want the point of this without the technical detail?

There is another piece about the same project written for non-engineers, and it makes a different argument: now that AI is this capable, what is left for people. Its spine is the sentence that overturned the whole direction — "still slow."

Read the plain-language version →

NEW POST

Two AI lamps — which one should you rub?

Claude Fable 5.1 and GPT-6 Astra launched two days apart at the same price. Not a benchmark shootout — one question only: if you want to start vibe coding, who should you pay?

Read: Battle of the AI Lamps →

Author:林政賢(Director · Gen AI creator & engineer · Founder of TangYi Studio)