I built a website with a single throne on it. Whoever clicked last owns it. Taking it is free and always will be. Keeping it costs a dollar a minute.
The whole thing rests on one design rule I decided not to break: money can defend the throne, but it can never buy it. You can pay the Royal Guard for immunity, and while that immunity runs nobody can touch you. The second it expires, a visitor who has never spent a cent takes your throne with one click. There is no leaderboard to climb and no bid to place. There is one seat, and you are always about to lose it.
That rule is what makes the site funny. It is also what makes it a concurrency problem, because "you are protected until 14:32:07, and free game at 14:32:08" is a promise the server has to keep under race conditions, against people who have a financial incentive to break it.
Here is what building that actually involved.
The entire game is one row
create table throne_state (
id int primary key default 1 check (id = 1),
holder_name text,
since timestamptz,
guard_until timestamptz,
usurpations int not null default 0,
treasury_cents int not null default 0,
fallen jsonb not null default '[]'::jsonb,
record_name text,
record_ms bigint not null default 0
);
The check (id = 1) is the important part. There is exactly one throne, so there is exactly one row, forever. Every coup is an update to that row. Every viewer in the world is reading it.
This is a nice property to design around: single-row state means the "who wins the race" question has one obvious answer, a row lock, and there is no sharding story to invent later. It also means the row is a hot spot, but for a site whose interesting case is a dozen people fighting over a click, that is not a real problem.
The only thing that really matters
The naive version of this game is written in JavaScript: read the state, check whether the guard has expired, and if it has, write your name. That version is broken in a way that costs money. Two clients read "guard expired" at the same moment and both write, or worse, a client simply skips the check, because the check lives on their machine.
So the guard check does not live in the client. It lives inside the transaction that performs the coup.
create or replace function usurp(p_name text)
returns json
language plpgsql security definer set search_path = public as $$
declare
s throne_state%rowtype;
grace_until timestamptz;
begin
select * into s from throne_state where id = 1 for update;
if s.guard_until is not null and s.guard_until > now() then
return json_build_object('ok', false, 'reason', 'guarded',
'guard_until', s.guard_until);
end if;
grace_until := s.since + interval '5 minutes';
if s.since is not null and grace_until > now() then
return json_build_object('ok', false, 'reason', 'grace',
'grace_until', grace_until);
end if;
-- ... bury the fallen king, then:
update throne_state set
holder_name = left(coalesce(nullif(trim(p_name), ''), 'anonymous coward'), 60),
since = now(),
guard_until = null,
usurpations = usurpations + 1
where id = 1;
return json_build_object('ok', true, 'since', ...);
end $$;
select ... for update takes a row lock. Whoever gets it first evaluates the guard against now() and either takes the throne or is refused, and the loser evaluates the same condition against a row that has already changed. Two simultaneous coups cannot both succeed. A coup cannot land one millisecond before a guard expires.
The client still runs the same check, and the client's check is entirely cosmetic. It exists so the button can say "Protected" instead of "Usurp". If someone patches it out in their console, the database refuses them anyway and the site tells them how many seconds of immunity remain. I like features where the honest path and the cheating path lead to the same place.
Note that the function returns JSON with an ok flag rather than raising. A refused coup is not an error, it is a game event, and it comes back with the timestamp the interface needs to draw a countdown.
What row-level security is actually protecting
The security model is short enough to quote in full:
create policy "public read throne" on throne_state for select using (true);
grant execute on function usurp(text) to anon;
revoke all on function grant_guard(int, int) from public, anon, authenticated;
grant execute on function grant_guard(int, int) to service_role;
Anyone can read the throne. Nobody can write to it directly. The free action, usurping, is exposed to anonymous callers as a security definer function, so the rules travel with the operation instead of being reimplemented in whatever client happens to call it. The paid action, granting immunity, is not exposed at all: revoked from every public role, granted only to service_role, which means the only thing on earth that can extend a guard is my Stripe webhook holding the service key.
That single revoke is the difference between "protection you buy" and "protection anyone can grant themselves with a fetch call".
A grace period with no column
New rulers get five minutes of immunity for free, so a fresh king has time to react before the vultures arrive. The obvious implementation is a grace_until column, which then has to be set on every coronation, cleared correctly, and kept consistent with the paid guard.
Instead it is derived:
grace_until := s.since + interval '5 minutes';
since already exists, because the site displays reign duration. The grace is a function of it. There is no second source of truth to drift, and changing the grace from sixty seconds to five minutes was a one-word migration in two functions.
The paid guard does have to know about it, otherwise buying protection during your grace would waste the overlap:
guard_until = greatest(
coalesce(guard_until, now()), -- extend a running guard
now(), -- or start from now
coalesce(since + interval '5 minutes', now()) -- or from the end of grace
) + make_interval(secs => p_seconds)
Three candidates, take the latest, add what you paid for. Buying early never costs you seconds, and stacked purchases add up instead of overwriting each other.
The Fallen, in the same transaction
The site keeps the last five dethroned rulers with the length of their reign. It is a capped list, so it lives in the row as jsonb rather than in a table of its own:
new_fallen := jsonb_build_object('name', s.holder_name, 'reignMs', reign_ms)
|| s.fallen;
if jsonb_array_length(new_fallen) > 5 then
select jsonb_agg(elem order by ord) into new_fallen
from jsonb_array_elements(new_fallen) with ordinality t(elem, ord)
where ord <= 5;
end if;
Prepend, trim, done, inside the transaction that performed the coup. There is no window where a king has been overthrown but not yet buried. The all-time record is computed in the same update with greatest(record_ms, reign_ms) and a case for the name.
The sixth-oldest king is deleted and forgotten, which is also the joke: the free part of the site is deliberately ephemeral, and only the paid listings are permanent.
The client never touches money
Every paid effect comes from a Stripe webhook and nothing else. The client's involvement in a purchase ends the moment it opens a checkout URL.
The interesting part is idempotency, because Stripe retries deliveries and will happily send the same successful payment twice. The pattern I used is insert-first:
const dedupe = await sb('/rest/v1/processed_events', {
method: 'POST',
body: JSON.stringify({ id: session.id }),
});
if (dedupe.status === 409) return Response.json({ received: true, duplicate: true });
if (!dedupe.ok) return new Response('Storage error', { status: 500 });
const releaseDedupe = () =>
sb(`/rest/v1/processed_events?id=eq.${encodeURIComponent(session.id)}`,
{ method: 'DELETE' });
processed_events has the Stripe checkout session id as its primary key, so the insert either succeeds, meaning this is the first time I have seen this payment, or it conflicts with 409, meaning I have already handled it and should acknowledge and stop.
The case that is easy to get wrong is the third one. If the insert succeeds but the fulfillment that follows fails, a naive implementation has just recorded "handled" for a payment it did not honor, and Stripe's retry will be swallowed by the deduplication. So every failure path deletes the row again before returning a 500:
if (!res.ok) {
await releaseDedupe();
return new Response('grant_guard failed', { status: 500 });
}
The customer paid. If my database is having a bad minute, I would rather have Stripe hammer me until it works than quietly keep the money.
Prices the client cannot choose
The site also sells permanent listings, and their price climbs: the first costs $1, the second $10, the third $100, the fourth $1,000, and every one after that adds another thousand. Feudal inflation.
The obvious mistake is to let the page compute the price and pass it to checkout. Instead the serverless endpoint counts the listings already sold and prices the next one itself:
const countRes = await fetch(`${SUPABASE_URL}/rest/v1/graves?select=id`, {
method: 'HEAD',
headers: { ...auth, Prefer: 'count=exact' },
});
const count = parseInt((countRes.headers.get('content-range') || '/0').split('/')[1], 10) || 0;
const slot = count + 1;
const dollars = postPrice(slot);
A HEAD with Prefer: count=exact gets the count out of the content-range header without transferring any rows. The front end contains the same postPrice function purely so empty slots can display "$100 · forever", and if the two ever disagree, the server wins, because the server is the one creating the Stripe session.
Identity without accounts
There is no login and no account. Friction kills the joke, and a game about clicking one button should not ask for an email address.
The site does need to know one thing: whether you are the current king, so it can show the buttons only a king can use. The answer is a coronation token. When your coup succeeds, the server returns the exact since timestamp of your reign, and the client keeps it:
const SINCE_KEY = 'dethrone_my_since';
function iAmKing(){ return state.holder && lastSince === state.holder.since; }
On every load it is compared against the live row. If it matches, you are the ruler and you get your buttons. If the throne changed hands while you were away, the token is cleared and you are informed, with some ceremony, that you have been overthrown.
This deserves to be said plainly: the token is not a security boundary. Anyone can put a timestamp in their own localStorage. It gates the interface and nothing else. It does not need to be stronger, because the operation it unlocks is "open a payment page", and the payment applies to whoever is sitting on the throne when the webhook fires, not to whoever clicked. Forging the token buys immunity for your enemy. I am comfortable with that failure mode.
Making it feel alive
Two mechanisms, one page.
Coups arrive through Postgres change streams. Every viewer subscribes to updates on that single row, so a coup in Sydney redraws the page in Paris before the usurper's own request has finished. A 2.5 second poll runs alongside as a safety net, because realtime connections drop and a stale throne is the one thing this site cannot afford.
The live audience counter uses presence instead of the database. Every open tab announces itself on a channel, and the size of the channel is the size of the court:
const presence = sb.channel('court', {
config: { presence: { key: crypto.randomUUID() } }
});
presence.on('presence', { event: 'sync' }, () => {
const n = Object.keys(presence.presenceState()).length;
render(n);
});
No table, no writes, no cookies, and it cleans itself up when people leave. It turned out to be the most persuasive feature on the page: a king who can see seven people watching has a very good reason to buy another minute of guard.
The hardest requirement in this project was not concurrency. It was French consumer law.
Selling a digital service to consumers in the EU gives the buyer a fourteen day right of withdrawal. There is an exemption for services fully performed immediately, which is exactly what this is, but the exemption only applies if the buyer expressly requests immediate performance and acknowledges losing the right of withdrawal. So the checkout has to collect that consent, and the terms have to spell it out.
consent_collection: { terms_of_service: 'required' },
Which means the funniest sentence I have ever had to write is also a legally operative one: buying immunity on a joke website involves formally acknowledging, before payment, that you will probably be dethroned anyway. The terms also had to define what "forever" means for a permanent listing, which is "for the lifetime of the site", and state that losing the throne after your immunity expires is the service working as described rather than a defect.
There is a lesson in there for anyone shipping a paid side project from Europe. The payment integration took an afternoon. The legal page took longer, and it is the part that would actually have hurt to get wrong.
Two CSS bugs that cost more than the race conditions
For balance, the two things that actually broke in production were not the interesting parts.
The first arrived with dark mode. I had colour tokens, --ink for text and --paper for background, and they swap in dark mode, which is the point of tokens. But one promotional block was written as background: var(--ink); color: #fff, so in dark mode it became white text on a light background. Any element meant to stay dark in both themes cannot borrow a token that flips. Two elements on the site now carry hardcoded colours for exactly that reason.
The second: a share button that was supposed to appear only for the king appeared for everybody. The markup had the hidden attribute, but the stylesheet had .share-x { display: inline-block }, and a class selector beats the browser's default [hidden] { display: none }. The fix is one line, .share-x[hidden] { display: none }, and I would rather confess it than have somebody find it.
What it is made of
One static HTML file. No framework, no build step, no bundler. Supabase Postgres for state, two serverless functions for Stripe, and a handful of create or replace function calls that hold the actual rules of the game. It deploys by uploading a folder.
I keep coming back to how much of this project's correctness lives in about sixty lines of PL/pgSQL. The client is disposable. If I rewrote the entire front end tomorrow in something fashionable, the game would remain exactly as fair, because the fairness was never in the front end.
And then nobody came
The honest ending: it works, it takes real money, it has been live for several days, and I am still my own only king. Building the throne turned out to be considerably easier than convincing anyone to steal it.
Dethroning me is free and takes one click, if you are the type to enjoy that sort of thing.
Top comments (1)
dethrone.click