Projects

What I build

Four things, all running. Each one below says what it is, what was actually hard about it, and where to go and use it.

Six daily puzzle games

A new board every day for Word, Bigger, Queens, Planets, Wires and a 5×5 mini crossword. All six are at /games, and every one keeps an archive of its past days.

The hard part is not the rules, it is producing a board a stranger can finish. Queens, Planets and Wires each grow a candidate board and then run a solver over the finished thing, counting answers with an early exit at two. Anything with a second answer is thrown away. Uniqueness is verified, not inferred.

Making that cheap enough to serve on request came from measurement rather than cleverness. Wires cuts a Hamiltonian path into pieces and merges them back together. Cutting straight to six pairs and hoping lands a unique board once in three hundred tries, and 23 of 30 dates ran out of attempts that way. Measured on random cuts, the share of boards that are unique runs 0% at four pairs, 0.3% at six, 3% at eight, 10% at ten and 28% at twelve — more dots pin a board down harder — so the generator starts tight and loosens one merge at a time, re-solving after each and refusing any merge that lets a second answer in. Over 1,200 dates: nothing ambiguous, nothing failed, a median of 59 ms.

Planets is 9×9 because smaller is impossible, not because it looked right. With two per row and column and no two touching, an exhaustive count of placements gives 0 at 6×6, 0 at 7×7, 2 at 8×8 and 664 at 9×9.

The crossword has the opposite constraint: every answer has to be a word somebody already wrote a clue for, so the fill searches a bank of a few hundred words per length instead of a dictionary. Taking the first fill a grid shape admits shipped the same puzzle on 318 of 1,200 dates, because a tidy shape has almost no fills at all — so each build now draws a number of fills to pass over, which varies the day and lets thin shapes fail fast. Over 1,200 dates, 3.3 years: 1,200 distinct grids, no repeats, a median of 38 ms.

Word carries enough answers to run three years before one repeats, and a test asserts that count rather than a comment claiming it.

None of it is stored. A board is a pure function of its date, so there is no puzzle database to keep in sync and no migration when a generator changes. The endpoint will only answer for a date its own clock says is playable: tomorrow’s answers are not hidden behind a flag, they do not exist yet.

A customer CMS and publishing pipeline

Not public: it sits behind the client login. It is where someone edits a site and where the edit becomes a deploy.

Content moves draft → version → published, and the three live in separate Firestore collections rather than as states on one document, so editing a draft cannot disturb what is currently being served. Each version carries a SHA-256 hash of the content it describes, so a version and its content cannot quietly drift apart.

No browser writes any of it. The rules on the draft and published collections are allow write: if false, and every change goes through a server-side call instead.

Deploys are run by a worker that claims a queued job in a Firestore transaction, then walks it through preflight, building and deploying, writing progress at each step — so a dashboard can follow a deploy it is not itself running. Before it builds, it re-checks that the requesting membership and the workspace are both still active.

The rule the whole thing is built around: a deploy ships code, never content. Writers add or merge. Nothing replaces a document a client last saved.

A content ingestion API

A writing agent posts a finished article to the site as JSON with a bearer token. It is validated against a schema, upserted on its id, and answered with a receipt. It is readable a moment later: article pages are Cloud Functions that read Firestore per request, so publishing costs no build and no deploy.

The body is untrusted Markdown. It renders with markdown-it at html: false, so raw HTML in the source is escaped rather than parsed, and the default link validator refuses javascript:, vbscript: and non-image data: URLs.

Each post also arrives with an editorial research summary that must never be published. It is stored in its own top-level field rather than inside the document, so code that projects the document wholesale onto the public site cannot pick it up by accident.

Slugs are checked against a reserved list, because Firebase Hosting ranks an exact-match static file above a rewrite. A post slugged about would not fail; it would silently never render.

This site

A Next.js static export on Firebase Hosting, with Cloud Functions behind rewrites for the parts that have to be current: the writing index, every article, the sitemap and the daily puzzle endpoint.

The split is the whole design. A statically generated article page is exact-match static content, which outranks the rewrite that serves ingested posts — so a pre-built article could never be corrected by sending it again. Articles stay function-served for that one reason.

This page, the homepage and the one about me are checked for horizontal overflow at six widths down to 320px, and the public routes for serious accessibility violations, on every browser run.