# How Square Town runs a live 3D pixel wall on Cloudflare's free tier

> How Square Town runs a live 3D favicon wall on Cloudflare Workers, D1 and one Durable Object — WebSockets, Stripe webhooks, a favicon proxy — on the free tier.

Source: https://square.pov.town/blog/build-a-pixel-wall-on-cloudflare-free-tier

[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](/blog/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](/how-it-works); what it means for buyers is in [how to pick a good square](/blog/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](/blog/square-launch).)

## 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](/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.
