We let anyone put our games on their own site. It is the one distribution channel in our plan that is supposed to compound: a site embeds a game, their visitors play it, some of them come here.

The route shipped. The snippet builder shipped. Framing worked cross-origin. And then we ran one command:

grep -rn 'href="/embed' src/

Zero hits outside the embed pages themselves.

The channel worked perfectly and was unreachable. Nothing on 78 game pages, no footer link, nothing in the nav. Which means every single embed had to be preceded by outreach from us — turning the one channel that should compound into something strictly linear in hours spent emailing people.

Before believing a shipped channel works, grep for who links to it. A page that only an email can reach is not a channel. The fix was a button on all 78 game pages and a footer link.

Build the snippet in one place

A page offering a copy-paste snippet has to construct it exactly once.

Ours was already written twice inside a single file — a server-side default and a picker script — and the two had drifted in whitespace. That sounds harmless. The part a drift silently drops is the credit link, which is the only part of the snippet with any value to us, because it sits on the host's page, outside our frame, where a search engine can see it.

Extracted to one module. Same lesson as two places that knew the same thing, in a different room.

The one link that must stay untagged

We tag outbound links with campaign parameters. The credit link in the embed snippet is the exception, and it is a firm one.

It is editorial, it sits on somebody else's page, and it is asked for by the licence. A tracker-tagged href in a snippet you are asking a stranger to publish reads as an ad placement — which is what a host objects to, and what a search engine discounts.

Tag the links where we control the click: the in-frame pill, the attribution bar, the end card. Never the one we are asking somebody else to paste.

The play beacon, and two silent ways it reports zero forever

Our API's default parser was JSON-only, project-wide, so the first version of the beacon rejected every real request with a 415. Silently — the caller is fire-and-forget with a swallowed rejection. The programme would have reported zero plays forever, with nothing in any log to explain it.

It was caught only because a test posted the way the browser actually posts. Write the endpoint test in the request shape production uses, not in your framework's default.

And switching the browser to JSON is not the fix. application/json is not CORS-safelisted, so it triggers a preflight — and navigator.sendBeacon cannot preflight. A JSON body silently loses the game_end event, which is the only one carrying the final engaged-seconds total. The answer is to accept form bodies and send URLSearchParams, which stays a simple request on both paths.

Never put a third-party analytics tag on a page that renders inside somebody else's site. It injects their tag into the host's page and falsifies the "no ads, no third-party trackers" promise, which is the most persuasive line in the whole pitch. A first-party cookieless beacon to our own API does the job. Host identity comes from the referrer, which browsers already reduce to an origin — the non-personal granularity is enforced by the browser rather than by our restraint.

Count embed plays in a separate field. Our catalogue ranks by play count. Folding embed plays into it lets one popular host reorder our own site for visitors who have never heard of them.

Then it happened again, differently

Months later, our embed index was live and linked all 78 games. 77 of those 78 embed URLs returned 404.

The embed route is the only one that fetches per game — the game page builds its paths from a single list request, but each embed page needs that game's detail. So it fired 78 requests in one Promise.all.

Our own rate limit is 10 requests a second with a burst of 20. Measured against the live API: 78 concurrent detail requests produced 11 successes and 67 rejections. The same slugs sequentially: 12 of 12.

The fetch helper returns null on failure by design, a .filter(Boolean) read that as "this game has no embed page", and the build went green while shipping a tenth of the channel.

A fan-out in a static build's path generation is a silent half-build waiting to happen. The fix was a pacer at the single choke point — concurrency 4, 125ms between starts, so 8 requests a second under a limit of 10 — plus a way for a route to throw when it cannot be correct with missing data. Pace under the limit, never at it; the retries and everything else on that IP need the headroom.

The check that would have caught it must compare links, not counts

Our first attempt compared the number of built embed directories against the number of game directories. It passed at 78 versus 78 — by coincidence. The embed folder held 77 game directories plus the index page, and the game folder held 78 because one of our games is a deliberate canonical duplicate that embed skips.

Two wrongs, one number, green.

The check now parses the embed index for the URLs it publishes and asserts each one has a built page. That is the exact invariant that broke, and it is immune to duplicates and to whatever else lands in that folder later.


See it: embed our games

Related reading: two places that knew the same thing · what breaks when you frame a game on a phone