Buy a square — $1

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.

Published 2026-08-189 min readMarkdown ↗

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 pending row that reserves the cell for 30 minutes; the Stripe webhook flips it to paid and broadcasts. /favicon/:domain proxies 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_cents is 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 an Upgrade: websocket header, creates a WebSocketPair, calls state.acceptWebSocket(server) and returns 101 with the client half. That acceptWebSocket is the hibernation API — idle sockets don't keep the object (or the bill) awake.
  • /broadcast (internal, POST): reads the body and ws.send()s it to every socket in state.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 →