The site
Architecture
How the site is put together, from a request arriving to the data it keeps.
On this page
One website serves every game. Solo games run in your browser; multiplayer games run on the server, which sends each player only what they may see. This chapter is the map: how a request reaches the code, what each part owns, how the site is deployed and configured, and what it stores.
It’s one Django project, obstacles, with a dozen small apps. It runs on Heroku as an ASGI app, so a card table can hold a live connection open. It keeps everything in Postgres and calls Node for the few jobs that need a game’s JavaScript engine on the server. Later chapters go deeper: the table service, Referees and anti-cheat, Accounts, privacy and analytics, Dailies and Ratings.
A request, end to end#
- Heroku’s router. HTTPS ends here. The router forwards plain HTTP to a dyno, with the browser’s scheme in
X-Forwarded-Proto. It sets two limits the code is built around: a request must start answering within 30 seconds (the referees’ timeouts sit under it), and a connection silent for 55 seconds is dropped (the table stream speaks well inside it). - gunicorn, with Uvicorn’s worker. The
Procfile’swebline runs gunicorn withuvicorn_worker.UvicornWorker: gunicorn manages processes, Uvicorn speaks ASGI inside each. The worker count is gunicorn’s default, or theWEB_CONCURRENCYconfig var. - The ASGI app.
obstacles/asgi.pyis Django’s stockget_asgi_application(). Ordinary views are synchronous, and Django runs each in a thread. The one async view is the table stream. - Ten middleware, in order (below).
- The URLconf,
obstacles/urls.py, picks a view. - A view draws a template, answers a JSON call or handles a table call. Some call a Node referee. Table changes are announced to streams through
tables/notify.py.
Procfilelines 1–2release: python manage.py migrate --noinput && python manage.py ratings backfill
web: gunicorn obstacles.asgi:application -k uvicorn_worker.UvicornWorker --graceful-timeout 10 --log-file -
The release line runs on deploys (below). --graceful-timeout 10 gives a worker ten seconds to finish when a dyno restarts; open table streams are cut, and browsers reconnect.
The middleware#
obstacles/settings.py MIDDLEWARE · lines 60–74MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"obstacles.middleware.CanonicalHostMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
# (a feedback post too big to take is turned away before CSRF reads its body)
"feedback.middleware.TooBigMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
# Must be last: it locks out repeated failed sign-ins.
"axes.middleware.AxesMiddleware",
]
The order matters in these places:
SecurityMiddlewarefirst. In production it redirects HTTP to HTTPS, trusting the router’sX-Forwarded-ProtothroughSECURE_PROXY_SSL_HEADER, and adds HSTS andnosniff.CanonicalHostMiddlewaresecond. Ours: it redirects every other allowed host (theherokuapp.comaddress,www.) to the one real address.WhiteNoiseMiddlewarebefore sessions and auth, so serving/static/…never costs a database query.SessionMiddlewaresets a cookie only when something is stored in the session, which in practice means signing in.AuthenticationMiddlewaremakesrequest.userlazily, so nothing is read until a view asks.CsrfViewMiddlewarechecks every POST exceptcsrf_exemptviews: the table calls, which carry a seat’s secret instead of a cookie, and the analytics beacon.AxesMiddlewarelast, as django-axes requires. It turns a locked-out sign-in into the “Too many tries” page.
The canonical-host middleware is short:
obstacles/middleware.py CanonicalHostMiddleware · lines 5–17class CanonicalHostMiddleware:
"""Send every other host (www., the herokuapp address) to the one real
address, once CANONICAL_HOST is set. Off when it isn't, so the site keeps
working on its herokuapp address until the domain is live."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
canonical = settings.CANONICAL_HOST
if canonical and request.get_host().split(":")[0] != canonical:
return HttpResponsePermanentRedirect(f"https://{canonical}{request.get_full_path()}")
return self.get_response(request)
It’s off until CANONICAL_HOST is set, so the site ran on its Heroku address before the domain was live. Once set, http://www.<the domain>/dailies/ takes two hops, both cached by browsers: SecurityMiddleware sends it to https://www.…, then the canonical check permanently redirects to https://<the domain>/dailies/, keeping path and query. A host not in ALLOWED_HOSTS never gets this far (request.get_host() refuses it). Being synchronous, it costs one thread hop per request under ASGI.
The URLconf#
obstacles/urls.py urlpatterns · lines 8–51urlpatterns = [
path("", games.home, name="home"),
path("robots.txt", games.robots, name="robots"),
path("sitemap.xml", games.sitemap, name="sitemap"),
# The Home Screen app's manifest (docs/adr/0029).
path("manifest.webmanifest", pwa.manifest, name="manifest"),
# A game's leaderboard lives inside the game and wears its look
# (docs/adr/0002-games-own-their-extras.md). One account works in every
# game: its home is the hub's /account/, and its sign-in and password
# pages are also under any game with pages of its own, in that game's
# look (accounts/urls.py, docs/adr/0011; The Register's addresses are
# unchanged).
path("games/the-register/leaderboard/", include("leaderboard.urls")),
# Hashi's leaderboard and its calls (today's puzzles, posting a solve).
path("games/hashi/", include("hashi.urls")),
# Solitaire's leaderboard and its calls (today's deals, posting a win).
path("games/solitaire/", include("solitaire.urls")),
# Chess Puzzles' leaderboard and its calls (today's three, posting a solve).
path("games/chess/", include("chesspuzzles.urls")),
# Every puzzle on the pencil-puzzle kit (Star Battle, …): its leaderboard
# and its calls, at /games/<slug>/…, for registered puzzles only
# (puzzles/registry.py, docs/adr/0023).
path("", include("puzzles.urls")),
# Every game's daily on one page (/dailies/, the hub's) and every game's
# Stats tab (/games/<slug>/stats/, the game's): docs/adr/0034.
path("", include("dailies.urls")),
path("", include("accounts.urls")),
# Card games share one table service, mounted under each game, and keep
# signed-in players' finished games (docs/adr/0010).
path("games/<slug:slug>/tables/", include("tables.urls")),
path("games/<slug:slug>/history/", include("tables.history_urls")),
path("games/", include("games.urls")),
# The technical guide: how every system works, from the code (docs/adr/0035).
path("guide/", include("guide.urls")),
# A signed-in player's feedback, emailed to the owner (docs/adr/0038).
path("feedback/", include("feedback.urls")),
# The owner's Analytics, staff only: the site's counts and each account's
# activity. It was /stats/, easy to take for a player's Stats tab.
path("analytics/", include("analytics.urls")),
path("stats/", RedirectView.as_view(pattern_name="analytics:overview", permanent=True)),
path("api/", include("leaderboard.api_urls")),
path("api/", include("analytics.api_urls")),
path("admin/", admin.site.urls),
]
Django tries these in order and the first match wins:
- Specific before general. Each game’s own routes (
games/the-register/leaderboard/,games/hashi/,games/solitaire/,games/chess/) come beforegames/, whose<slug:slug>/would claim every/games/<anything>/. A game’s page itself, such as/games/hashi/, isn’t in its include, so it falls through togames.views.play. - The puzzle kit can’t shadow anyone. It’s included at the root, but its path converter matches only registered puzzle slugs (
puzzles/urls.py), so/games/hashi/leaderboard/never reaches it. - Accounts at two addresses. One include of
accounts.urlsgives every account page an address on the hub (/account/…) and under each game with pages of its own (/games/<slug>/account/…). Accounts explains why. - Tables under each game.
games/<slug>/tables/is the table service’s JSON API and stream;games/<slug>/history/is a signed-in player’s kept games. - The rest.
/analytics/is staff only (/stats/redirects there);/api/holds The Register’s scores and the analytics beacon (/api/events/).
A shared table link, /games/<slug>/t/<code>/, is games.views.play with the code passed along, so the page opens straight into joining.
The apps#
obstacles/settings.py INSTALLED_APPS · lines 35–58INSTALLED_APPS = [
"accounts",
"games",
"leaderboard",
"hashi",
"solitaire",
"puzzles",
"chesspuzzles",
"analytics",
"tables",
"crowd",
"ratings",
"dailies",
"guide",
"feedback",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.humanize", # thousands separators in card history
"axes",
]
| App | What it owns |
|---|---|
games/ |
The shelf (the home page), each game’s page, and games/catalog.py, the list of games. No models: a game ships with a deploy. Static files are in games/static/games/<slug>/. |
accounts/ |
The User model (email, display name, house rules, How to play seen), signing in and up, passwords, /account/, deleting an account. |
leaderboard/ |
The Register’s dailies, posted solves, referee and boards. |
hashi/ |
Hashi’s dailies, posted solves, referee and checker, boards. |
solitaire/ |
Solitaire’s wins, losses and boards, and the daily deals it had until 2026-10-22 (ADR 0037), kept for what was won on them. |
puzzles/ |
The pencil-puzzle kit’s server side: the registry (puzzles/registry.py), dailies, an archive (one a day, from settings.ARCHIVE_FROM), posting, boards. |
chesspuzzles/ |
Chess Puzzles’ dailies from the bundle, solves and misses, boards. |
dailies/ |
The Dailies page at /dailies/ and every game’s Stats tab. Stores nothing; it reads the other apps. |
ratings/ |
Every signed-in player’s rating in every game, worked out from results the other apps keep. |
crowd/ |
Anonymous answer tallies for party games (“how many people pick this?”). |
analytics/ |
Counted events, in the site’s own database, and the owner’s page at /analytics/ (staff only). |
tables/ |
Every multiplayer game: tables, seats, bots, spectators, the live stream, each game’s rules, kept results. Its Django label is still cards (its first games were card games), so its tables and migrations keep that name. |
guide/ |
This guide. No models. |
feedback/ |
A signed-in player’s feedback, emailed to the owner, at /feedback/. No models: nothing is kept (Accounts). |
Apps reach each other’s data through narrow doors: the account page asks each game for a summary through a dotted path in the catalog, ratings read results through ratings/sources.py, and a table game reaches the crowd’s tallies only through the table service.
Two kinds of game#
A game’s kind tells you where its rules run. Both kinds use the house kit and differ in the catalog only by table=True.
Games that run in your browser. The Register, Hashi, Solitaire, Chess Puzzles and the pencil puzzles are static files under games/static/games/<slug>/. Django serves one page (games/templates/games/<slug>.html); the game then runs in the browser and keeps your progress in localStorage. It talks to the server only when the page carries data-hub="1" and its data- URLs, and only for today’s dailies, posting a solve (a referee checks it) and counting starts and finishes. Without the switch the files still play and send nothing.
Games the server runs. Every multiplayer game is a table. Its rules are Python in tables/<slug>/, its whole state is one JSON column on Table, bots move on the server, and each browser gets only its own seat’s view. The browser draws what it’s sent and posts what you do; it never decides anything. See the table service.
Live tables: ASGI, a stream and a doorbell#
A table’s page keeps one long request open, GET /games/<slug>/tables/<code>/stream/, and the server writes a new snapshot down it whenever the table changes. This is server-sent events (text/event-stream), read with fetch() instead of EventSource, because the request must carry the seat’s secret in the X-Seat header and EventSource can’t send headers.
Holding hundreds of these open is why the site is ASGI: an async view waiting on a queue costs a coroutine, not a thread. The view is tables.views.stream:
tables/views.py stream · lines 323–383async def stream(request, slug, code):
"""Server-sent events: the table's snapshot each time it changes. A
fetch() stream, not EventSource, so it can carry the X-Seat header."""
_game(slug)
code = str(code).upper()
secret = _secret(request)
watch = _watch(request)
# What's been said that this browser has heard (?talk=n): none given,
# it hears from now on.
heard = _after(request)
# Listen before the first look, so a change in between isn't missed.
queue = notify.subscribe(code)
try:
first = await _look(slug, code, secret, True, watch=watch, heard=heard)
except BaseException:
notify.unsubscribe(code, queue)
raise
if first[0] is None:
notify.unsubscribe(code, queue)
raise Http404("No such table")
async def events():
try:
shown, snap, wake, said = first
yield f"data: {json.dumps(snap)}\n\n"
heard = said["n"]
if said["said"]:
yield f"event: talk\ndata: {json.dumps(said)}\n\n"
started = last_check_in = time.time()
while time.time() - started < STREAM_LIFE:
wait = KEEPALIVE if wake is None else max(0.05, min(KEEPALIVE, wake - time.time() + 0.02))
try:
await asyncio.wait_for(queue.get(), wait)
except TimeoutError:
pass
check_in = time.time() - last_check_in > KEEPALIVE
if check_in:
last_check_in = time.time()
try:
seen, snap, wake, said = await _look(slug, code, secret, check_in, known=shown, watch=watch, heard=heard)
except DatabaseError:
logger.exception("Couldn't read table %s; trying again", code)
await asyncio.sleep(1)
continue
if seen is None:
return
if said["said"]:
yield f"event: talk\ndata: {json.dumps(said)}\n\n"
heard = said["n"]
if seen != shown:
shown = seen
yield f"data: {json.dumps(snap)}\n\n"
elif not said["said"]:
yield ": still here\n\n"
finally:
notify.unsubscribe(code, queue)
response = StreamingHttpResponse(events(), content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return response
- Subscribe first, then look, so a change between the two isn’t missed.
- Wait on the queue for at most 10 seconds, sooner if a timer or bot is due (
wake), then look again. If the view has changed, send adata:line with the snapshot. If not, send the comment: still here, so Heroku’s router doesn’t treat the connection as idle. - End after 900 seconds and let the browser reconnect, so a dyno’s connections keep turning over.
Every database touch is inside _look, wrapped in sync_to_async, which hands its connection back when done: a stream lasts minutes, and a connection per open tab would exhaust a small plan. Connections come from a pool, a few per worker (DB_POOL_MAX, below), because a change wakes every stream at its table at once. Without one, each would open its own connection at the same moment, and Postgres refuses past its limit. A load test (ADR 0049) found three 20-person tables were enough to lose half their updates that way; with the pool, 480 watchers at 40 busy tables lost none.
How a change rings the doorbell#
Every change to a table happens in a transaction, with the table’s row locked. After it commits, the service calls notify.publish(code) in tables/notify.py:
tables/notify.py publish · lines 51–59def publish(code: str) -> None:
"""Call after the change is committed."""
_deliver(code)
if connection.vendor == "postgresql":
try:
with connection.cursor() as cursor:
cursor.execute("SELECT pg_notify(%s, %s)", [CHANNEL, code])
except Exception:
log.exception("Couldn't announce a change to table %s", code)
_deliver puts the code on every queue in this process waiting for that table, crossing from the changing thread to each stream’s event loop with call_soon_threadsafe. But a dyno can run several worker processes, and the stream may be in another one. So on Postgres, publish also runs pg_notify on the channel uo_cards, and every process has exactly one listening thread:
tables/notify.py _listen_forever · lines 71–91def _listen_forever() -> None:
import time
import psycopg
keys = ("dbname", "user", "password", "host", "port", "sslmode")
params = {k: v for k, v in connection.get_connection_params().items() if k in keys and v}
# TCP keepalives, and a query every half minute, so a connection that's
# silently gone is noticed and replaced rather than listened to forever.
params.update(keepalives=1, keepalives_idle=30, keepalives_interval=10, keepalives_count=3)
while True:
try:
with psycopg.connect(**params, autocommit=True) as conn:
conn.execute(f"LISTEN {CHANNEL}")
while True:
for note in conn.notifies(timeout=30):
_deliver(note.payload)
conn.execute("SELECT 1")
except Exception:
log.exception("Table notifications dropped; reconnecting")
time.sleep(2)
One LISTEN connection per process, not per player, keeps the database’s connection limit safe however many people watch. The listener starts with the first stream, reconnects after any error, and pings every half minute to catch a connection that died silently. Tests turn it off (CARDS_LISTEN = False in obstacles/settings_test.py), because a listening connection would stop the test database from being dropped.
SQLite (local development) has no NOTIFY, so only _deliver runs, and streams are live within one process only. That’s why local development runs one Uvicorn process with no --workers. SQLite also gets transaction_mode="IMMEDIATE" and a 20-second lock timeout, because several streams write check-ins at once and should wait for the lock, not fail.
The doorbell carries only the table’s code. Each stream reads the table and draws its own seat’s view, so secrets stay per seat even though the notification goes to everyone.
If a stream can’t stay open (a buffering proxy, a flaky network), the room kit falls back to polling GET …/tables/<code>/, which does the same check-in and tick and returns the same snapshot. runserver can’t serve streams, so under runserver tables poll.
Node, called from Python#
Five referees need a game’s JavaScript engine on the server, to rebuild a puzzle from its code or replay a posted win: referee/solve.mjs (The Register), referee/hashi.mjs, referee/solitaire.mjs, referee/chess.mjs and referee/puzzles.mjs (every kit puzzle). Python runs one Node process per call, with settings.NODE_BINARY (node unless the environment says otherwise). The kit’s call is typical:
puzzles/referee.py run · lines 39–55def run(command, payload):
try:
done = node.run([SCRIPT, command], input=json.dumps(payload), timeout=TIMEOUT)
except subprocess.TimeoutExpired as e:
raise RefereeDown("The referee took too long with this puzzle. Try posting again in a moment.") from e
except OSError as e:
raise RefereeDown("The referee isn’t working just now. Try posting again later.") from e
if done.returncode == REFUSED:
log.warning("The referee wouldn't %s %s: %s", command, payload.get("slug"), done.stderr.strip()[:200])
raise RefereeError("The referee couldn’t make this puzzle, so it can’t be checked.")
if done.returncode:
log.warning("The puzzle referee failed on %s: %s", command, done.stderr.strip()[:200])
raise RefereeDown("The referee isn’t working just now. Try posting again later.")
try:
return json.loads(done.stdout)
except ValueError as e:
raise RefereeDown("The referee isn’t working just now. Try posting again later.") from e
JSON goes in on stdin and comes back on stdout. Exit status 2 means the engine refused (a puzzle it won’t make); any other failure is the referee’s own. The timeout, 20 seconds, sits under Heroku’s 30.
Every referee runs through obstacles/node.py, which allows 1 Node process at a time across the whole dyno (NODE_AT_ONCE). The slot is a lock file, so both workers see it. A kit puzzle takes 50 to 75 MB to build and a Register case 250 to 330 MB, on a 512 MB dyno the app already half fills: six Register checks at once peaked at about 1.3 GB unlimited and 370 MB in turn (ADR 0049). Waiting for a slot counts toward the caller’s timeout.
Results are cached (a SolvedCase, a Puzzle), so each puzzle is built once. A big Register case can take a dozen seconds, so leaderboard/referee.py also checks cases ahead on a background thread, when someone starts a case and when the day’s cases are made. Referees and anti-cheat has the rest.
Deploying#
Merging to main deploys: Heroku’s GitHub integration builds the new commit with two buildpacks, Node first, then Python. Node stays at runtime for the referees (its version is engines in package.json). Python installs the project with uv from pyproject.toml and uv.lock, and runs collectstatic, so WhiteNoise serves hashed, compressed files (CompressedManifestStaticFilesStorage outside development).
The release phase then runs, before the new code takes traffic:
python manage.py migrate --noinput.python manage.py ratings backfill, which rebuilds a game’s ratings only if they’re missing or were worked out under older rules (VERSIONinratings/service.py). See Ratings.
If either fails, Heroku doesn’t release, and the old dynos keep serving.
Old code still runs while migrations apply, so Seat.covered and DailyPuzzle.picture have a db_default and Table.room is nullable: older code can still insert seats, make a day’s puzzles and make tables. Ratings survive it too: a ladder is rebuilt only when its rules are older than the running code’s VERSION, so an old dyno never rebuilds a ladder the new release just rebuilt (Ratings). Check the live site after a deploy.
Settings#
obstacles/settings.py reads everything secret or environment-specific from the environment: Heroku config vars in production, a .env file locally. Their values never appear in the code, the guide or a log.
| Variable | What it’s for | Without it |
|---|---|---|
ENVIRONMENT |
dev locally. Anything else turns on production safety: HTTPS redirect, secure cookies, HSTS, compressed static files. |
dev |
DEBUG |
Debug pages, only in development. | on in dev, off otherwise |
SECRET_KEY |
Signs what Django signs (a session’s check, password-reset links) and derives the daily codes. | a dev-only stand-in locally; outside dev, the site won’t start |
ALLOWED_HOSTS |
Hosts the site answers to, comma separated. Also sets CSRF_TRUSTED_ORIGINS. |
localhost, 127.0.0.1, .localhost in dev |
CANONICAL_HOST |
The one real address; others redirect to it. | no redirect |
DATABASE_URL |
Postgres, read by dj-database-url (SSL required outside dev). | SQLite, db.sqlite3 |
DB_POOL_MAX |
Connections each worker’s pool may hold. Two workers × this, plus one LISTEN each, must fit the plan’s limit with room for a release command and a heroku run. 0 turns the pool off. |
7 (fits Essential-0’s 20) |
RESEND_API_KEY |
Email through Resend over SMTP. A send gives up after EMAIL_TIMEOUT, 10 seconds. |
emails print to the console |
DEFAULT_FROM_EMAIL |
Who email comes from. | the site’s hello address |
FEEDBACK_TO |
Where feedback is emailed, comma separated for several (Accounts). | in dev, the console; elsewhere, no page offers Feedback |
NODE_BINARY |
Where Node is, for the referees. | node on the PATH |
NODE_AT_ONCE |
Referees in Node at once, across the dyno. Raise it on a bigger dyno. | 1 (fits a 512 MB dyno) |
UO_ONE_DAILY_FROM |
Moves the day the site switched to one daily per game, for testing either side (Dailies). | the real switch day |
UO_ARCHIVE_FROM |
Moves the first day a kit puzzle’s archive lists (never before the switch); older dailies stay, unlisted (Dailies). | 2026-10-22 |
UO_WEEK_RAMP_FROM |
Moves the first day whose daily follows the week, gentlest Monday to hardest Sunday. Keep it a Monday after the deploy (Dailies). | 2026-10-26 |
DYNO |
Set by Heroku. Behind Heroku’s router a request’s address is the last X-Forwarded-For entry (the one the router adds), not REMOTE_ADDR (the router’s own). django-axes’ lockout and The Register’s case warming use it (obstacles/client.py). |
REMOTE_ADDR |
UO_BOT_NAMES |
ordered (dev only) gives bots the first free name in their list instead of a random one, so the layout and details checks see the same names every run (Testing). |
random names |
Also: TIME_ZONE is America/Chicago, because the site’s day turns over at midnight Central time. AUTH_USER_MODEL is accounts.User. A session lasts 5,184,000 seconds (two months). REFEREE_WARM lets The Register’s referee check cases in the background; it’s off in tests (obstacles/settings_test.py, which also uses a fast password hasher). obstacles/wsgi.py remains for tools that want WSGI, but the site is served as ASGI.
The Home Screen app#
The whole site can be added to a phone’s Home Screen as one app (ADR 0029). Django serves the web app manifest, not a static file, because the icons’ URLs are hashed static names:
obstacles/pwa.py manifest · lines 24–46@require_safe
def manifest(request):
"""One app for the whole site: it opens on the shelf, and every game and
page is inside its scope, so nothing opens in a browser sheet."""
response = JsonResponse({
"id": "/",
"name": "Unnecessary Obstacles",
# What a Home Screen shows under the icon: about a dozen characters fit.
"short_name": "Obstacles",
"description": "Small games for curious grown-ups, by David Mays. Free, in your browser.",
"lang": "en-US",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": PAPER,
"theme_color": PAPER,
"categories": ["games"],
"icons": icons(),
}, json_dumps_params={"indent": 2})
response["Content-Type"] = "application/manifest+json"
# Short, so a changed icon or name reaches people soon; browsers revalidate.
response["Cache-Control"] = "public, max-age=3600"
return response
start_url and scope are /, so every page opens inside the app, and display: standalone hides the browser’s bars. theme_color and background_color are PAPER, #f4efe3, the shelf’s paper (games/static/games/house/paper.css, ADR 0036), so the splash and bar match the first page. It’s cached for an hour. There’s no service worker: Chrome no longer needs one to offer Install.
Every page’s head includes templates/house/app_head.html, which links the manifest and adds the tags iOS reads: the icon, the title and an opaque black status bar. Pages range from cream paper to dark felt, and a translucent bar’s white clock would vanish on the paper. For a signed-in player it also adds what Feedback needs (Accounts). The house kit’s install.js suggests adding the app after a finished game, never on the hub (the front end).
Search engines and link previews#
What a search engine or a link preview reads never changes what a player sees (ADR 0048). It all lives in the page’s head, or in what a browser shows with JavaScript off.
- Search copy.
games/seo.pygives each game a title and a description in the words people search with (“Star Battle: a free daily logic puzzle”). The box’s tagline stays on the page. The copy never uses a trademarked name of a classic a game follows, and claims only what’s true;tests/test_seo.pyholds both. - One head for every game page. The
seo_headtag (games/templatetags/seo.py, drawingtemplates/house/seo_head.html) writes the title, description, canonical address, preview card and schema.orgVideoGamedata. A table’s own address (/t/<code>/) previews as “Come play …”, isnoindex, and points its canonical at the game’s page. - Without JavaScript,
templates/house/seo_noscript.htmlsays what the game is and links to the shelf and the game’s guide chapter. robots.txtandsitemap.xmlare two small views ingames/views.py. The sitemap lists the shelf, the Dailies page, every listed game and this guide;robots.txtkeeps crawlers out of what’s a player’s own or a moment’s:
| Kept out of search |
|---|
| /account/ |
| /games/*/account/ |
| /games/*/history/ |
| /games/*/stats/ |
| /games/*/tables/ |
| /games/*/t/ |
| /games/*/leaderboard/ |
| /api/ |
| /analytics/ |
| /feedback/ |
| /admin/ |
| /stats/ |
games/views.py, UNLISTED- Preview cards are 1200×630 PNGs in
static/img/og/, made from the running site’s box art bytools/house/cards.mjs. Rerun it after a box or tagline changes, or a game launches.
The game catalog#
The games are declared in code, in games/catalog.py, since each ships with a deploy. A game is a frozen dataclass:
games/catalog.py Game · lines 29–103@dataclass(frozen=True)
class Game:
slug: str
title: str
tagline: str
template: str
thumbnail: str
color: str # the box's tile color on the shelf (docs/design-system.md)
# Base template for the game's Django-served pages (account pages, card
# history), in its own look (docs/adr/0002). Blank: it has none.
pages: str = ""
# What the hub's account page says about a player's time in this game
# (docs/adr/0011): a dotted path to a function(user, game) returning
# {"lines": [...], "link": (label, url)}, or None when there's nothing
# yet. Blank: the game keeps nothing for players.
account_summary: str = ""
# Played at a table the server runs (tables/, docs/adr/0014); its slug
# is registered in tables/games.py.
table: bool = False
# On the shelf and in "Your games". A game being built stays off them
# (its address still works) until it launches.
listed: bool = True
# How the shelf sorts and filters it (docs/adr/0020). `family` is one of
# FAMILIES' keys; `players` the (fewest, most) people at once, bots not
# counted; `solo` whether you can play it by yourself (a puzzle, or a
# table you can fill with bots); `daily` whether it has a daily
# challenge. The defaults suit a one-player game with no daily.
family: str = "puzzle"
players: tuple[int, int] = (1, 1)
solo: bool = True
daily: bool = False
def __post_init__(self):
low, high = self.players
if self.family not in FAMILY_KEYS:
raise ValueError(f"{self.slug!r}: family must be one of {FAMILY_KEYS}, not {self.family!r}")
if not 1 <= low <= high:
raise ValueError(f"{self.slug!r}: players must be (fewest, most) with 1 <= fewest <= most")
@property
def with_others(self) -> bool:
"""Can be played with other people, not only alone."""
return self.players[1] > 1
@property
def meta(self) -> str:
"""The small line on its box: how many play, and a daily if it has one."""
low, high = self.players
count = f"{low}" if low == high else f"{low}–{high}"
text = f"{count} player{'' if high == 1 else 's'}"
return f"{text} · Daily" if self.daily else text
@property
def table_game(self) -> bool:
return self.table
@property
def hub(self) -> bool:
return not self.slug
@property
def url(self) -> str:
return reverse("games:play", kwargs={"slug": self.slug}) if self.slug else reverse("home")
def summary(self, user) -> dict | None:
"""What this game keeps for `user`, for the hub's account page. A game
whose summary fails is logged and shown without one (UNAVAILABLE),
rather than taking the page down with it."""
if not self.account_summary:
return None
try:
return import_string(self.account_summary)(user, self)
except Exception:
log.exception("Couldn't sum up %s for account %s", self.slug, user.pk)
return UNAVAILABLE
The comments in the code explain each field. What they don’t say:
tablegames must also be registered intables/games.py, and a test checks both ways.pagesmakes the game’s Django-served pages (leaderboard, account pages, history, Stats tab) look like the game, not the hub (ADR 0002); blank means none, and so no account pages under it.account_summaryis what the game shows on/account/(ADR 0011).- The shelf sorts by
family,players,soloanddaily(ADR 0020). Brainteasers became a family in ADR 0042, and The Register joined them in ADR 0043.__post_init__rejects an unknown family or a nonsense player count, so a typo fails at import, not on the shelf.
GAMES is every game, GAMES_BY_SLUG the same by slug, and listed() the ones on the shelf. At 8 listed games the shelf gets family sections, and at 12 filter chips (a kind of game; Play alone or With others). The daily chip was dropped in ADR 0043, since the Dailies page above the shelf leads to the dailies. shelf() filters on the server from the query string, so the shelf works without script; shelf.js repeats it in the browser.
One more Game isn’t on the shelf: HUB, with a blank slug and pages set to base.html. It lets the hub’s own account pages use the hub’s look through the same machinery as a game’s (Accounts):
games/catalog.py with_pages · lines 627–630def with_pages(slug) -> Game | None:
"""The game, if it has Django-served pages of its own (and so account pages)."""
game = GAMES_BY_SLUG.get(slug)
return game if game and game.pages else None
games.views.play looks the game up, counts a visit and renders its template. The page owns the whole viewport and carries its own link back to the hub.
games/views.py play · lines 19–26def play(request, slug, code=""):
game = GAMES_BY_SLUG.get(slug)
if not game:
raise Http404(f"No game named {slug!r}")
record(request, "visit", game=slug)
# Games are standalone documents that own the whole viewport and palette;
# the way back to the hub is a link each game carries.
return render(request, game.template, {"game": game, "table_code": code.upper()[:6]})
The data model#
A few patterns repeat across the apps.
A game is a string, not a foreign key. Models name their game by slug (Table.game, Rating.game, Event.game, puzzles.Puzzle.kind), because the catalog is code. A new game needs no migration, and a removed game’s history stays readable.
Three layers for each solo game’s results:
| Layer | The Register | Hashi | The kit | Solitaire | Chess Puzzles |
|---|---|---|---|---|---|
| What the referee worked out, cached | SolvedCase |
Puzzle |
Puzzle |
Winnable |
(the bundle) |
| The day’s dailies | DailyCase (until 2026-10-24) |
DailyPuzzle |
DailyPuzzle |
DailyDeal (until 2026-10-22) |
DailyChess |
| A player’s verified result | Score |
Solve |
Solve |
Solve, Loss |
Solve, Miss |
- A result points at its player with
CASCADE, at its daily withSET_NULL(set only when posted that day), and at a cached puzzle (The Register, Hashi, the kit) withPROTECT, so a puzzle can’t vanish under someone’s time. Solitaire and Chess Puzzles name the deal or puzzle by code instead. - A unique constraint allows one result per player per puzzle (
one_hashi_solve_per_player_per_puzzleand its siblings), so a retried post can’t count twice. The kit has one for puzzles with no daily and one per daily (one_kit_solve_per_player_per_daily), since a daily’s code can also be an endless puzzle’s (Low Poly’s is the picture’s own). - A brainteaser’s kit solve keeps the moves it was scored on (
Solve.moves: Chicago Traffic’s slides, Codebreaker’s guesses, ADR 0042), and its boards rank by moves first. - A kit daily whose code names a picture in a library that may change keeps the picture too (
DailyPuzzle.picture, for Griddles). - Each cache is unique on code, size or variant, and engine version, because a code makes a different puzzle under a different engine. Hashi’s and the kit’s daily rows record their engine version too, so a bump the engine can still step back from (its
VERSIONS) changes no day already made (a day keeps its engine version). - Solitaire’s losses and Chess Puzzles’ misses exist for ratings: giving up counts against you.
Tables, live and kept. A live Table holds a short code, the whole state as JSON, a version that rises with every change (so a browser knows to redraw), wake_at (when a timer or bot is due) and room (who’s watching, waiting or moved). A Seat holds a person’s name and a hash of their secret, or a bot’s level; Seat.user is set only when a signed-in player links it. Entry is the public game log. A table idle for 14 days is deleted. When a game ends by the rules with at least one seat linked to an account, it’s copied into Result and ResultSeat, which outlive the table (Result.table is SET_NULL). No game adds a migration: everything a game keeps is in its JSON state.
Ratings point at results by name. Rating is one per player per game. RatedEvent marks a result as rated by a key like result:12 (not a foreign key, since results live in several apps). Change is what one result did to one player (and what a result sheet says of it: Change.sheet(), its milestones established and high). Ladder, one row per game, is the lock every ratings change takes first. All of it can be rebuilt from the results.
Some things belong to no one. crowd.Tally counts answers, never who gave them. analytics.Event names an account only for a signed-in player, and drops it when the account is deleted.
The account. accounts.User signs in by email, ignoring case (Accounts), and has no username. display_name is unique, ignoring case. house_rules is one JSON blob of a player’s default table settings, keyed by game, so a new table game needs no migration; howto_seen lists the slugs of games whose How to play has already opened by itself (ADR 0039). What happens to the rest when an account is deleted is in the figure, and in detail in Accounts.