How Square Town runs a live 3D pixel wall on Cloudflare's free tier
One Worker, one SQLite database, one Durable Object, three.js in the browser. No servers, no bill, which is the point.
Square Town (square.pov.town) is a live, 3D favicon wall that runs entirely on Cloudflare's free tier: a single Worker serves the static site and the API, a D1 (SQLite) database holds every square, one Durable Object called WallHub holds every open WebSocket and fans out events, and the browser renders the wall with a vendored copy of three.js. Stripe handles money. There is no server to keep alive, which is why "1 square = $1, forever" is a promise the hosting bill can't break.
TL;DR — Worker + static assets + D1 + one Durable Object + Stripe Checkout. Purchases create a
pendingrow that reserves the cell for 30 minutes; the Stripe webhook flips it topaidand broadcasts./favicon/:domainproxies Google's favicon service with a CORS header and a one-day edge cache so WebGL can use icons as textures. Free-tier headroom: 100k Worker requests/day and 5M D1 reads/day. Everything below is read from the code, not from a slide.
The whole stack in one table
| Piece | What it is | What it does on Square Town |
|---|---|---|
Cloudflare Worker (src/worker.js) |
One JavaScript file at the edge | Routes /api/*, /go/:id, /favicon/:domain, /ws; everything else falls through to static assets |
Static assets (public/) |
Served by the Workers assets binding | index.html (the wall), deed.html, admin.html, vendor/three.module.min.js |
D1 (faviconwall) |
Serverless SQLite | Tables listings, clicks, events, meta |
Durable Object WallHub |
One global instance named wall |
Accepts WebSocket upgrades on /ws, broadcasts every event to all sockets |
| Cloudflare edge cache | caches.default |
Caches proxied favicons for a day |
| Stripe | Checkout Sessions + one webhook | Takes the $1 (or $100 for the Throne, or $5 for analytics), confirms via checkout.session.completed |
| three.js (vendored) | WebGL in the browser | Orthographic camera; top-down = 2D wall, ctrl+drag = 3D towers (why the wall is 3D) |
Infra names are still faviconwall (Worker, D1 database, npm package) because renaming them would orphan the domain, the Durable Object and the DB. The custom domain is one line in wrangler.jsonc.
The database is four tables of SQLite
schema.sql is short. listings is the wall: one row per square with url, domain, x, y, w, h (always 1×1 except the 4×4 Throne), a status that goes pending → paid → evicted, price_cents and sq_price_cents (the per-square price, which doubles on eviction), a deed_token (a random UUID that is ownership — no accounts), clown (1 = no favicon, NULL = unknown), clicks, views, kisses, analytics_paid, evicted_by, watered_at (last kiss, unix ms; the column kept its old name) and created_at. clicks is per-day analytics: (listing_id, day) unique, n clicks and v views. events is the live feed: type, text, ts. meta holds the one number that matters, unlocked_half: the half-width of the live area, 8 at launch (a 16×16 box), +8 per ring unlock, up to 512.
Two indexes: listings(status) and listings(deed_token). A tiny lazy migration in the Worker adds the views, kisses and v columns to older databases, ignoring "duplicate column" errors — SQLite has no ADD COLUMN IF NOT EXISTS.
A purchase is a pending row, a Stripe session and a webhook
POST /api/buy takes { url, mode, x, y, evictId }. The Worker normalises the URL (adds https://, strips www.), reads the unlocked half-width, loads all active listings — paid rows plus pending rows younger than 30 minutes — and validates the block by mode:
- pick (the normal case): the cell must be inside the unlocked box, not the Throne, not the Throne's aura ring, and not overlapping any active listing. Price is
BASE_CENTS= 100. - evict: the target must be a paid listing; the block is the target's exact footprint;
sq_price_centsis the target's ×2. If a pending listing already overlaps the target, you get "Someone is already evicting this square. Vultures everywhere." - center: the Throne,
CENTER_CENTS= 10000, only if nothing overlaps it.
It inserts a pending row with a fresh deed_token, then creates a Stripe Checkout Session over plain fetch to api.stripe.com/v1/checkout/sessions (form-encoded; no SDK) with metadata[kind]=buy, metadata[listing_id], metadata[evict_id], and a success_url of /?deed=<token>. The browser gets { checkoutUrl, deed, price, block }. The pending row is the reservation: for 30 minutes (PENDING_TTL_MS) it counts as occupied, so two people can't buy the same cell; abandoned checkouts simply stop counting. Nothing to clean up.
POST /api/stripe-webhook verifies the Stripe-Signature header by hand: parse t and v1, reject if the timestamp is more than 300 seconds off, HMAC-SHA256 ${t}.${body} with the webhook secret via WebCrypto, compare hex. On checkout.session.completed with kind=buy it calls completePurchase: set status='paid', mark the victim evicted with evicted_by if there was one, log a buy or evict event (which also broadcasts), check whether the ring should unlock, and kick off the clown check in ctx.waitUntil. kind=analytics just sets analytics_paid=1.
In DEV_MODE (or whenever STRIPE_SECRET_KEY is missing) the Worker skips Stripe and completes the purchase immediately, which is how local dev works with no keys at all.
Ring unlocks are one integer in the meta table
After every completed purchase, maybeUnlockRing sums w*h over paid listings, divides by the live box area (2·half)², and if that's at least UNLOCK_FILL = 0.7 it bumps unlocked_half by RING_STEP = 8 and logs "🔓 RING UNLOCKED! The wall just grew to 32x32". At launch the box is 16×16 = 256 cells, so the first unlock lands when 180 squares are paid (the Throne counts as 16 if someone has it). The locked part of the grid is never drawn by the client, so the wall never looks empty. Details in how it works; what it means for buyers is in how to pick a good square.
One Durable Object holds every WebSocket
Real-time is one Durable Object class, WallHub, one instance addressed by idFromName("wall"). Its fetch handles two paths:
/ws: requires anUpgrade: websocketheader, creates aWebSocketPair, callsstate.acceptWebSocket(server)and returns 101 with the client half. ThatacceptWebSocketis the hibernation API — idle sockets don't keep the object (or the bill) awake./broadcast(internal,POST): reads the body andws.send()s it to every socket instate.getWebSockets(), swallowing errors from dead ones, and returns the count.
The Worker's logEvent writes the event to D1 and then calls broadcast — a fetch("https://hub/broadcast") on the DO stub, in a try/catch because real-time is best effort; the client polls /api/state every 30 seconds anyway. Kiss broadcasts carry the listing id and the kisser's anonymous client id so every browser plays the 💋 burst except the sender's. Click broadcasts ({ type: "click", id, clicks }) skip the feed but go out live, so a tower grows on every open screen when someone clicks through.
The DO answers "ping" with "pong"; the client pings every 25 seconds and reconnects with backoff capped at 30 seconds. wrangler.jsonc declares it with "new_sqlite_classes": ["WallHub"] in a v1 migration (the SQLite-backed Durable Object flavour).
The favicon proxy exists because WebGL needs CORS
Square Town never stores an image. Every icon comes from Google's favicon service (https://www.google.com/s2/favicons?domain=…&sz=…), which is free, cached and reliable. But three.js loads icons as textures, and WebGL refuses cross-origin images without an Access-Control-Allow-Origin header, which Google's service doesn't send. So the Worker exposes GET /favicon/:domain?sz=128 (size clamped to 16–256): check caches.default; on a miss, fetch upstream with redirect: "follow", re-wrap the body with access-control-allow-origin: * and cache-control: public, max-age=86400, and ctx.waitUntil(cache.put(...)) if upstream was OK. The wall loads icons at 128 px through this route; the card that opens when you click a square uses Google's URL directly at 64 px, since an <img> doesn't need CORS.
The same service is the clown detector: it answers 404 (via its gstatic faviconV2 redirect) for domains with no favicon and 200 otherwise. checkClown treats 404 as clown, 200 as fine, anything else as "transient, leave unknown".
Clicks are a 302 with a side effect
GET /go/:id looks up the paid listing, appends utm_source=square.pov.town&utm_medium=referral&utm_campaign=square (unless the destination already sets them), returns a 302, and in ctx.waitUntil increments listings.clicks, upserts today's row in clicks, and broadcasts the new count. Views (POST /api/view/:id) do the same with the v column, sent once per square per browser session. Neither is a backlink — it's a redirect and a counter.
Free-tier headroom, and why that makes "forever" credible
The relevant limits, as documented in the repo: 100,000 Worker requests per day and 5 million D1 reads per day on the free plan. Everything Square Town does is a handful of D1 statements per request, favicons are served from the edge cache after the first hit, and WebSockets on the hibernation API cost nothing while idle.
That is the real answer to "how can you promise forever for $1?" A wall dies when the operator stops paying for it. Square Town's monthly cost at launch scale is zero, the code is one file plus one HTML page, and there's a wrangler deploy between any laptop and a running copy. It's forever* because nothing needs renewing; the asterisk is evictions, not hosting. (Context: the launch post.)
What's deliberately not there
Honesty, since this is the engineering post. No rate limiting on kisses or clown rechecks — Cloudflare rate-limiting rules are the plan if it gets trolled. Two people whose checkouts for the same cell both complete would both be marked paid; the 30-minute reservation blocks the normal path, and at launch scale that's fine. Deeds are bearer tokens: no login, no reset. And three.js is vendored at public/vendor/three.module.min.js, so the page's only third-party runtime dependency is Google's favicon service.
If you want the wall's data, GET /api/state returns all of it as JSON — grid size, unlocked half, sold count, revenue, every paid listing with domain/x/y/clicks/views/kisses, pending footprints, the last 20 events, the clowns. That's the whole public API.
FAQ
What is Square Town built on?
Cloudflare Workers (one Worker with static assets), D1 (SQLite) for storage, one Durable Object (WallHub) for WebSockets, Stripe Checkout for payments, and three.js in the browser for the 2D/3D wall. It runs on Cloudflare's free tier.
How does Square Town keep two people from buying the same square?
A purchase inserts a pending row before Stripe Checkout opens; pending rows younger than 30 minutes count as occupied. The Stripe webhook flips the row to paid; abandoned checkouts simply age out.
How are live updates delivered?
A single Durable Object holds every open WebSocket via the hibernation API. Every server event (buy, evict, kiss, unlock, clown, click) is written to D1 and then broadcast to all sockets. Browsers also poll /api/state every 30 seconds as a fallback.
Why does Square Town proxy favicons instead of loading them from Google directly?
WebGL textures require CORS headers, which Google's favicon service doesn't send. /favicon/:domain re-serves the icon with Access-Control-Allow-Origin: * and caches it at the edge for a day.
What are the free-tier limits Square Town lives under?
As documented in the repo: 100,000 Worker requests per day and 5 million D1 reads per day. Static assets and edge-cached favicons don't burn those budgets on repeat views.
Does Square Town store logo images?
No. It stores a URL and a domain per square. Icons are fetched from Google's favicon service on demand and cached at the edge; the 3D tower colour is sampled client-side from that icon.
Put your favicon on the wall
1 square = $1, one-time, no account. Paste a URL, pick a cell, done. Open the wall →