Project · Architecture
How This Site Is Built
This site is one program running at the edge of the network, a folder of hand-written files, and no build step at all. It serves nine pages of prose and eight live rooms that six people can join at once, and the whole thing is checked by more than forty gates before any of it reaches you. This page explains how, at whichever depth you would like it.
Reading level
The plain-English version. Every section also has a technical note.
What it is
A personal site that is also the place I try things out — a CV, some long-form writing, and a set of workshop tools teams can actually run.
Most of what is here is not a page to read. The games are live rooms: a facilitator opens one, up to eight people join it with a short code, and what everybody sees on screen updates together. That is a very different problem from serving a CV, and nearly every decision below comes from having to do both from one place without the reading half paying for the live half.
The shape of it
One Cloudflare Worker, with the repository root as its asset directory, two KV namespaces for state that outlives a request, and one Durable Object class per game for state that has to be live. Browser code is hand-written ES5-flavoured JavaScript with no dependencies; server code is ES modules, because the Workers runtime loads nothing else. The two never mix inside one file.
One program, and no build step
There is no compiler, no bundler and no framework. The file I edit is the file the browser receives, and the only thing installed is the tool that uploads it.
That is a deliberate constraint rather than a stage I have not reached yet. A build step is a second program standing between what I wrote and what you loaded, and every one of those has to be understood, updated and trusted. Without one, editing a file and refreshing the browser is still the entire workflow, and nothing can be broken by a dependency I did not choose to change.
The site started on Cloudflare Pages, which is the obvious home for a folder of files. It moved to a Worker in August 2026 for one reason: a Pages project cannot contain the piece that makes a live room possible. Rather than run two things, one deployment now does both jobs.
What "no build step" costs and buys
One development dependency, wrangler, and it is a deploy
tool — nothing under node_modules is ever served. No CDN
script tags, no third-party webfonts, nothing pulled at runtime from
anywhere but this origin. The price is paid in the stylesheet: roughly
six thousand hand-written lines, and a palette that has to be declared
three times over — once for light, once for the operating system's dark
preference, and once for the manual theme toggle — because plain CSS
cannot combine a media query with an attribute selector and there is no
build step to fold them together. A colour token added to two of the
three breaks exactly one mode, so a content gate checks all three.
Why Pages could not stay
A Durable Object — Cloudflare's "one always-live server per room" — has
to be exported from a Worker's entry point, and a Pages project cannot
hold one. The move brought the API handlers across unchanged: they are
imported by the router exactly as they were and handed the same
request and env a Pages Function received.
That mattered most for the endpoints anyone can POST to without an
account, where the validation inside the handler is the entire defence.
Importing them rather than rewriting them meant no rejection could be
lost in the move by accident.
A request, start to finish
Every request hits the Worker first. It decides whether you are asking for a live room, an API, or a page — and pages get edited on the way out before they reach you.
The order matters. Before anything else, the Worker asks whether the thing you have requested belongs to a feature that is switched off — and that check covers the live rooms and the APIs, not just the pages, so turning something off actually turns it off rather than merely hiding the link to it.
Pages are then edited on the way out. Which project cards appear, and in which order, is not written in the file on disk; the Worker rewrites the page as it streams past. That is what lets the site work with JavaScript switched off entirely: nothing is hidden in your browser, because the thing you were not meant to see never left the server.
The route table, in order
The feature gate runs first and covers everything below it. Then the
per-game socket, poll and act routes; then the QR endpoint; then the
shareable summary of a finished session; then a small table of exact
API paths; then the tidy-up redirects; then the switches page; and
finally, everything else is a file. Any method other than the one a
route expects gets a 405 with an allow header rather than
falling through to the file server. HEAD is answered by
the GET handler, because the runtime discards the body
anyway.
Two redirects that are not the same redirect
Both Pages and Workers tidy a page's .html address away
to the bare one, but Pages answered with a permanent 308 and the
Workers asset server answers with a temporary 307. Left alone, the migration would have
quietly downgraded every permanent URL on the site to a temporary one —
not a thing to do to a CV that people bookmark. The Worker issues the
308 itself. It was measured against the live site rather than assumed.
The rewrite, and the bug it caused
Rewriting happens with the streaming HTML rewriter, so nothing is buffered. The subtle part is caching: the asset server answers "has it changed?" from the ETag of the file on disk, and the file on disk does not change when a card is switched off. A browser that had ever loaded the home page was told "not modified" and went on showing its own stale copy — while every check by hand passed, because a command-line request has no cached copy and never sends the header that triggers it. Pages are now fetched unconditionally, and any response the Worker edits has its ETag and last-modified date stripped and is marked not to be stored. Files that are only ever themselves — the stylesheet is the big one — keep their 304s.
Security headers live in the code
Every response carries its headers from one place in the Worker, not from a dashboard toggle: in the repository they are versioned, they appear in a diff, and a gate can prove they are on the wire. The content policy is strict because this site can afford it — no external script, stylesheet, font or image anywhere. The one inline script is the theme applied before first paint, and it is allowed by hash rather than by a blanket permission; a gate recomputes those hashes from the files on disk, so editing the script and forgetting the policy fails the build instead of the page. Strict transport security ships at one day rather than one year on purpose, because it is a one-way door a reader cannot click past.
Where things are kept
Almost nothing is kept. What is kept falls into two piles: settings and records that must survive, and the live state of a room in progress.
The reading half of the site stores nothing at all — no analytics, no cookie for tracking, nothing following you between pages. Your theme choice and your reading level on this page are saved in your own browser and never sent anywhere.
The two key-value stores
One holds the summaries a finished estimation session writes, each with an expiry so a shared link ages out rather than living for ever. The other holds site settings: which features are switched on, the order the project cards appear in, the tag overrides behind the home-page filter, the counters behind the rate limits, and a probe the health endpoint writes to prove the store is actually reachable rather than merely configured. Both are eventually consistent and are treated as such — nothing in a live room depends on a read from either.
What a live room actually is
A room is a small server that exists only while people are in it, holds everybody's connection at once, and decides on its own what each person is allowed to see.
That last part is the interesting one. In a retro, or an estimation round, or a game where people write things for each other, the whole exercise fails if one person can see another's answer early. The rule here is that a thing you are not entitled to see is not hidden on your screen — it is absent from what was sent to you. Each person's view is worked out separately and posted to that person alone.
Rooms also have to survive being ignored. They go to sleep when nobody is talking and wake up unchanged, which means nothing important is ever only in memory.
The base and the games on it
One Durable Object class per game, all extending a shared base that knows nothing about any game. The base handles sockets, seats, tokens, reconnection, hibernation, storage, alarms and rate limiting; it takes a rules engine — a create, apply and views-for triple — and gives it somewhere to run. The engines are pure: no DOM, no clock, no randomness, all injected, which is what makes them testable directly. Every class is separate rather than one object with a mode flag, so an event from one game cannot reach another's room.
The line that carries the guarantee
The broadcast sends each socket exactly the view prepared for its own token and nothing else. Assembling one shared payload and letting each page filter it would be fewer messages, and would put every hidden card into every browser. The engines prove absence; the broadcast is what makes that proof worth anything, and it now lives in one place for every game rather than one copy per game.
Hibernation, replay and the numbers
The room is read from storage at the top of every handler and written at the bottom, which is what lets it hibernate safely. One game rebuilds its round by replaying the events rather than storing it, because the round was a closure and a closure does not survive hibernation. The limits: four kilobytes for a single message, thirty messages per ten seconds counted against the seat rather than the socket (so a churn of reconnections buys nothing), and at most three sockets per seat — a phone, a laptop, and one stale connection that has not timed out yet.
When the socket cannot connect
Some networks refuse WebSockets. A room can also be read and played over ordinary polling: arriving is quick, at a couple of seconds, and leaving is deliberately slow — ninety seconds before a silent seat is treated as gone, because the cost of being slow is a name lingering on screen and the cost of being quick is dropping somebody whose train went into a tunnel. "Still here" is only written down every fifteen seconds, which is far inside that threshold and costs a fraction as much.
Switches that fail open
Every project on the site can be switched off from a page anyone can reach — and if the thing holding those settings breaks, everything comes back on rather than going dark.
That direction is the whole decision. A settings store that is missing, unreachable or corrupt must not be able to take the site down with it, so "I do not know" means "show it". The failure mode is a card appearing that I meant to hide, which is recoverable; the alternative failure mode is a blank site, which is not.
The switches page has no login, on purpose. What protects it instead is that it cannot do much: a fixed list of things it may name, a rate limit, and no switch for itself, so it can never be used to hide the way back.
What a switch actually turns off
Each entry names its pages and its API prefixes. The prefix is the entry, not a tidier way of writing one path: narrowing it to the room-creation route would leave anybody already holding a room code playing on inside a switched-off feature. Every element carrying the matching attribute is removed from the page as it streams — a card, a nav link, a whole section — and the removal happens on the server, so it holds with scripting off. Switching everything off leaves a sentence rather than an empty list, because a heading with nothing under it reads as a page that failed rather than as a choice.
Order is data, and it lives in code first
The card order and the category tags are authored in the source and only overridden by the store. If they lived only in the store, the same wobble that fails open to "everything on" would fail open to "every card untagged", and the filter would quietly offer nothing while looking like it worked.
What has to go green
Over forty checks run before anything ships: the rules of every game, the markup, the links, the colour contrast, and the pages opened in a real browser with two tabs on one room.
They exist because I am not the one typing most of this. The work is built by an agent against acceptance criteria I agree first, and a gate that runs whether or not anybody remembers to look is the only kind worth having. Each one has to be declared in a single list and actually run in the pipeline; a check that is declared and never runs is worse than no check, because it reads as covered.
The tiers
Static gates read the markup, the links, the colour tokens and the prose — including a gate that reads what these long-form pages claim and fails when a page says something the code no longer does. Unit suites drive the pure engines directly. Socket suites open six real connections to one live room and sweep what each one is allowed to see. Browser gates open real tabs and make a single assertion each. Contrast is audited against WCAG AA as rendered, with an empty baseline that stays empty.
A pull request deploys to production
Unusual enough to say plainly: every commit pushed to an open pull request runs the static gates, and if they pass, that commit goes live. Not a preview — production. The trade is speed for coverage, and it has two consequences worth knowing. The visual gates (layout, themes, print, contrast, the two-tab room tests) run only on the merge to the main branch, which is after the code already shipped, so they report on production rather than guard it. And an abandoned pull request leaves its code live: nothing puts the main branch back on its own. Cloudflare's own build integration is deliberately disconnected, because reconnecting it would deploy every commit twice — once ungated, and the ungated one able to win the race.
One place that deploys
Both the pull-request and merge pipelines call the same deploy job, which has no gates of its own and must not gain any — whether the code deserves to ship is the caller's question, answered before it gets there. Every deploy in the repository queues behind every other one and is never cancelled in flight: a cancelled test run costs nothing, a cancelled deploy leaves the account halfway through a change. Deploying is also the only thing that applies a storage migration, which is a one-way record — so a pull request can apply one before it is merged.
What it costs, and what it cannot do
The design is cheap to run and quick to change, and it pays for that in hand-written code, a gap between shipping and checking, and no way of knowing who you are.
Refusing a build step means everything is written by hand, and the stylesheet is where that bill arrives. Letting a pull request deploy means the fastest possible feedback and a window where something has shipped before the slow checks have looked at it. And there is no sign-in anywhere on the site: a room is protected by a short code and by what the room refuses to send, not by knowing who anybody is. Accounts are the next thing this design has to make room for, and they are the first thing it would have to bring in from outside.
Known and accepted
Nothing asserts what lands on a printed page any more; that gate was deleted rather than kept as a check nobody ran, and the loss is recorded rather than papered over. The two written measurements on this page — the gate count and the size of the stylesheet — were taken in September 2026 and are the kind of number that goes stale quietly, so they are dated rather than trusted.