The README heading read 'Two people, two browsers', which sounds like a cap. It is not one: the server holds no game state at all, so any number of people play independent games and the server only ever sees a finished score. The only real cap is four to a game, and that is a keyboard sharing the same stand rather than anything technical. Checking that turned up a case where it would not have been true. A classroom, an office or a household all arrive from one address, and the submission limit of thirty an hour was low enough that a class finishing together would have started losing scores to a 429. Raised to 120 and verified with a burst of 130: the first 120 land, the rest are refused. What actually keeps rubbish off the board is the plausibility check, not this limit.
Lemonade scores service
The leaderboard everybody shares. A single Node process with SQLite, no build
step and no native modules: node:sqlite ships with the runtime and Node runs
the TypeScript directly.
API
| Method | Path | Purpose |
|---|---|---|
| GET | /api/health |
liveness, used by the container healthcheck |
| GET | /api/scores |
the top 50, best first |
| POST | /api/scores |
submit 1–4 players from one finished season |
// POST /api/scores
{ "entries": [ { "name": "ADA", "assets": 4635, "days": 12,
"glasses": 800, "seed": 1234, "broke": false } ] }
// 201 -> { "ids": ["17"], "scores": [ ...the new board... ] }
What it does and does not guarantee
Everything in a request is treated as hostile. Names are forced to a printable
uppercase subset and cut to 12 characters. Every number must be an integer in
range, and a score is refused if it could not have happened: assets above
$2.00 + $25 a day, or glasses above 400 a day, are rejected as impossible for
the days claimed. Bodies are capped at 4 KB and submissions at 120 an hour per
address, which is why TRUST_PROXY=1 matters behind nginx — otherwise every
request looks like it came from the proxy and one busy classroom would lock
everyone else out.
It cannot prove a score is real. There are no accounts and no signing, so anyone willing to craft a request can post a plausible score under any name. The checks stop nonsense and casual spam, not a determined forger. That is a deliberate trade for a game with no sign-up; if the board ever needs to be trustworthy it needs identities, which is a different piece of work.
Only the best 500 rows are kept; the rest are pruned on write.
Running it
npm run scores # from the repo root, on :5184
npm run dev:all # game and scores service together
Environment: PORT (5184), DB_PATH (./data/scores.db), TRUST_PROXY
(1 behind a proxy).
Deploying
docker compose -f server/compose.yml up -d --build
The container is read-only apart from the named volume holding the database —
that volume is the one piece of state that must survive a redeploy. In front
of it, nginx terminates TLS and proxies /api/ through:
location /api/ {
proxy_pass http://127.0.0.1:5184;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
The game itself is a static bundle and needs no server; point it at another
origin with VITE_SCORES_API=https://example.org/api at build time, or serve
both from one origin and leave it on the default /api.