Technical guide

The site

Accounts, privacy and analytics

What the site keeps in your browser and on its server, the optional account that works across every game, and the anonymous counts that show whether anyone’s playing.

Checked against the code on 2026-10-25.

On this page
  1. What’s stored where
    1. In your browser
    2. On the server
  2. Cookies
    1. The table API doesn’t use cookies
  3. One account, many looks
    1. Two addresses, one name
    2. The look
    3. Staying in the look
    4. Where you land
  4. The account
  5. Signing in, and being locked out
    1. What axes keeps
  6. Password reset emails
  7. Feedback
  8. Your account page
  9. Deleting an account
  10. Analytics
    1. An event
    2. Recording one
    3. The beacon and the per-browser id
    4. Analytics, the owner’s page
    5. What’s never recorded

You never need an account to play here. Every game keeps your progress in your own browser, and the server sees only what you send it, like a solve you post. A free, optional account keeps verified results (scores, times, table history, ratings) on the server under a display name, in every game at once. The site also keeps anonymous counts of play.

Three decisions shape this:

  • ADR 0005: usage is counted in the site’s own database, with no cookies and nothing that says who someone is.
  • ADR 0010: one account for every game, never required, and the table API stays cookie-free even for signed-in players.
  • ADR 0011: your account’s home is on the hub, at /account/, while every game keeps its own sign-in pages in its own look.

AGENTS.md rule 6 sums it up: players’ progress stays in their browser.

What’s stored where#

In your browser#

Everything a game remembers for you lives in localStorage, under keys starting uo: (The Register’s older saved cases use register:game:). None of it is a cookie, so it isn’t sent with requests; a game sends at most a small part in a request body when you act.

Key What it holds Written by
uo:<slug>:options Your Options choices for a game (sound, helpers, look). the house kit’s Options sheet, the room kit’s
uo:<slug>:p:<puzzle>, uo:<slug>:recent Puzzles in progress: marks, moves, the clock (the kit keeps the latest 150). each puzzle’s store, e.g. games/static/games/puzzles/kit/store.js
uo:<slug>:tally, uo:<slug>:days, uo:<slug>:pending Solves by size, the days you solved a daily, solves waiting for you to sign in. the same stores
register:game:<id>, uo:pending The Register’s cases, and a solve waiting for sign-in. games/static/games/the-register/js/api.js, games/static/games/the-register/js/hub.js
uo:dailies, uo:dailies:run Every daily you’ve solved on its day, across games, and a run through today’s from the Dailies page. games/static/games/house/dailies.js (Dailies)
uo:install, uo:install:visit Whether and when the Home Screen prompt has asked. games/static/games/house/install.js
uo:cards:<game>:<CODE> Your seat’s secret at one table. games/static/games/room/kit/net.js
uo:cards:<game>:recent, uo:cards:name Your six latest tables in a game, and the name you sit down with. the same
uo:cards:<game>:house-rules A guest’s default table settings. games/static/games/room/kit/houserules.js
uo:visitor The anonymous per-browser id analytics counts players by (below). each solo game’s hub module

Storage can be blocked (in a private window) or full, so every read and write is wrapped: the game still plays, it just won’t remember. The house kit’s helper:

games/static/games/house/dom.jskeep · lines 38–49
/** localStorage that never throws: private windows and full disks just forget. */
export const keep = {
  get(key, fallback = null) {
    try { const v = localStorage.getItem(key); return v === null ? fallback : JSON.parse(v); } catch { return fallback; }
  },
  set(key, value) {
    try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch { return false; }
  },
  drop(key) {
    try { localStorage.removeItem(key); } catch { /* fine */ }
  },
};

So progress is per browser. Clear site data and it’s gone, and your phone and laptop each keep their own. The server never syncs it, signed in or not.

On the server#

What Where When it’s kept
Your account accounts.User: email, display name, password hash, house rules, the games whose How to play has opened by itself when you sign up
Verified results The Register’s Score; Solve in Hashi, the kit, Solitaire and Chess Puzzles; Solitaire’s Loss, Chess Puzzles’ Miss when you post one, signed in, and the referee agrees
Table history Result and ResultSeat (app label cards) when a game ends by its rules with at least one seat linked to an account
Ratings Rating, Change worked out from the results above (Ratings)
A live table Table, Seat, Entry: names as typed, a hash of each seat’s secret while the table exists; deleted after 14 idle days
Counts analytics.Event a page opened, a puzzle started or solved, a table started or finished
Crowd tallies crowd.Tally: how many gave each answer, never who when a party game counts answers
Sign-in records django-axes’s tables (below) each sign-in and each failed attempt
Feedback nowhere: emailed to the owner, then gone (below) never kept; sent only when a signed-in player presses Submit

Architecture has the whole data model.

Cookies#

The site sets at most three cookies, all Django’s own. A guest who only plays gets none.

  • The session cookie (sessionid) exists only once you sign in. Django sets it when something is stored in the session, and nothing is until login(). It’s HttpOnly, Secure in production, and lasts 5,184,000 seconds (two months). The session is a database row; the cookie holds only its random key.
  • The CSRF cookie (csrftoken) is set only when a page renders a CSRF token, for a form (as on the sign-in and sign-up pages) or a call that needs one.
  • The messages cookie (messages) carries a one-time note like “Welcome, Ray.” across a redirect. Every such note follows something only a signed-in player does.

A game’s page includes the CSRF token (as data-csrf on its root element) only for a signed-in player, who needs it to post a solve or link a seat:

{% if user.is_authenticated %}data-user="\{{ user.display_name }}" … data-csrf="\{{ csrf_token }}"{% endif %}

A guest’s game page has no token and sets no cookie; signing in reloads the page, which brings both. A signed-in player’s pages also carry a token in the head, for Feedback.

The hub’s home page sets no cookie for a guest: its account line is two plain links, not a form, on purpose. test_guests_see_one_quiet_line_offering_a_free_account_and_get_no_cookies in tests/test_accounts.py holds it. The Dailies page, leaderboards and guide are cookie-free for guests too.

The table API doesn’t use cookies#

A table seat is held by a secret, not a session. When you create or join a table, the server stores only the secret’s SHA-256 hash on the Seat and gives the secret to your browser, which keeps it under uo:cards:<game>:<CODE> and sends it as the X-Seat header. The room kit’s request helper sends no cookies:

games/static/games/room/kit/net.jscall · lines 65–81
async function call(url, { method = 'GET', body, secret, watch, signal } = {}) {
  const r = await fetch(url, {
    method,
    signal,
    headers: { 'Content-Type': 'application/json', ...who(secret, watch) },
    body: body ? JSON.stringify(body) : undefined,
    credentials: 'omit',
  });
  let data = null;
  try { data = await r.json(); } catch { /* not JSON */ }
  if (!r.ok) {
    const e = new Error(data?.error || (r.status === 404 ? "There's no table with that code." : "Couldn't reach the table. Try again."));
    e.status = r.status;
    throw e;
  }
  return data;
}

Another site can’t make your browser send that header, so these calls need no CSRF protection: creating, joining, acting and hosting are all csrf_exempt. A spectator sends X-Watch instead, a ten-character random id kept only in the page’s memory, so the table can count watchers. The server keeps a hash of it with a timestamp in Table.room, drops it once quiet, and never names anyone by it.

Four calls read the session, because each is about your account:

Call What it does Decision
POST …/tables/<code>/link/ Puts your account on your seat (it needs X-Seat too), so the game is kept in your history. ADR 0010
GET/POST …/tables/<code>/take/ Says which seat here is yours, or moves it to this device with a new secret. ADR 0018
GET/POST …/tables/house-rules/ Reads or saves your default table settings for the game. ADR 0021
POST /account/howto-seen/ Notes that a game’s How to play has opened by itself for you, so it won’t again on any device you’re signed in on. ADR 0039

Each is csrf_protect: its POST goes with credentials: 'include' and the page’s token as X-CSRFToken. Creating a table never reads the session, even for a signed-in host: the page fetches your house rules and sends them as the table’s options. So a create request is the same from anyone, and its table_started count carries no account. The table service has the rest.

One account, many looks#

A player signing in mid-puzzle shouldn’t land on what looks like another site. So every account page exists twice, on the hub at /account/… and under each game with its own pages at /games/<slug>/account/…, each in its own look. It’s one account behind both.

Two addresses, one name#

accounts/urls.pyPAGES · lines 20–31
PAGES = [
    ("signup/", views.signup, "signup"),
    ("login/", views.Login.as_view(), "login"),
    ("logout/", views.Logout.as_view(), "logout"),
    ("delete/", signed_in(views.delete), "delete"),
    ("deleted/", views.deleted, "deleted"),
    ("password/", signed_in(views.PasswordChange.as_view()), "password_change"),
    ("reset/", views.PasswordReset.as_view(), "password_reset"),
    ("reset/sent/", views.PasswordResetDone.as_view(), "reset_sent"),
    ("reset/<uidb64>/<token>/", views.PasswordResetConfirm.as_view(), "password_reset_confirm"),
    ("reset/done/", views.PasswordResetComplete.as_view(), "reset_done"),
]
accounts/urls.pyurlpatterns · lines 33–39
urlpatterns = [
    path("account/howto-seen/", views.howto_seen, name="howto_seen"),
    path("account/", in_game(signed_in(views.profile)), name="profile"),
    path("games/<slug:game>/account/profile/", in_game(views.to_hub), name="profile"),
    *(path(f"account/{route}", in_game(view), name=name) for route, view, name in PAGES),
    *(path(f"games/<slug:game>/account/{route}", in_game(view), name=name) for route, view, name in PAGES),
]

The module is included once, at the site’s root, and registers each page twice under one name: the hub’s route takes no arguments, the game’s takes game. Django’s reverse() picks the pattern that fits its arguments, so reverse("accounts:login") is /account/login/ and reverse("accounts:login", args=["hashi"]) is /games/hashi/account/login/. Including the module twice, once per prefix, would break reverse(): with two includes in one namespace, only one can be reversed.

The profile itself lives only at /account/. A game’s /games/<slug>/account/profile/ (its home before ADR 0011) redirects there.

Every view is wrapped in in_game, which turns a game’s slug into a 404 unless that game has its own pages (with_pages):

accounts/views.pyin_game · lines 29–36
def in_game(view):
    """Account pages exist on the hub and under a game that has pages of its own."""
    @wraps(view)
    def wrapped(request, *args, game="", **kwargs):
        if game and not with_pages(game):
            raise Http404("No such game")
        return view(request, *args, game=game, **kwargs)
    return wrapped

The look#

A context processor picks the page’s look, so no view has to:

accounts/context_processors.pylook · lines 4–11
def look(request):
    """The look a page wears (`look`): the game in the URL, if it has pages
    of its own, else the hub's. Account links carry its slug (none on the
    hub), so a player stays in the game they came from (docs/adr/0002,
    docs/adr/0011)."""
    match = getattr(request, "resolver_match", None)
    kwargs = match.kwargs if match else {}
    return {"look": with_pages(kwargs.get("game") or kwargs.get("slug")) or HUB}

It reads the resolved URL’s game (account pages) or slug (history pages, Stats tabs) and looks it up in the catalog. A game with pages gives its own Game. Anything else gets catalog.HUB, a stand-in Game with a blank slug and pages="base.html". Every account template starts {% extends look.pages %}, so one template draws itself as the hub, The Register, the card room or a puzzle, with a kicker showing the game’s title when look.hub is false. A page whose address has no slug, like Hashi’s leaderboard at /games/hashi/leaderboard/, sets look itself.

Staying in the look#

Links between account pages must stay in their look: “Forgot your password?” on Hashi’s sign-in page should open Hashi’s reset page. Two helpers do it:

accounts/views.pyaccount · lines 39–41
def account(name, game="") -> str:
    """An account page's address: the game's, or with no game, the hub's."""
    return reverse(f"accounts:{name}", args=[game] if with_pages(game) else [])
accounts/templatetags/account.pyaccount_url · lines 12–15
@register.simple_tag(takes_context=True)
def account_url(context, name):
    look = context.get("look")
    return account(name, look.slug if look else "")

A template writes {% account_url 'password_reset' %} and gets /games/hashi/account/reset/ on Hashi’s pages and /account/reset/ on the hub’s. Pages that need a signed-in player use signed_in, the site’s login_required, which sends a guest to sign in where they already are:

accounts/views.pysigned_in · lines 48–56
def signed_in(view):
    """login_required, but sending people to sign in where they are: within
    the game they're in, or on the hub."""
    @wraps(view)
    def wrapped(request, *args, **kwargs):
        if not request.user.is_authenticated:
            return redirect_to_login(request.get_full_path(), login_url(kwargs.get("game") or kwargs.get("slug")))
        return view(request, *args, **kwargs)
    return wrapped

Where you land#

After signing in or up, a safe next wins. It must stay on this site (Django’s url_has_allowed_host_and_scheme, HTTPS-only when the request was) and mustn’t be the current page, which would loop. Without one, a table game’s sign-in returns to the game’s page, any other game’s to the hub, and the hub’s to /account/. Signing out returns to a table game’s page, or otherwise to the hub.

A worked example. Ray, a guest, taps Sign up on a Hashi result sheet.

  1. He lands on /games/hashi/account/signup/?next=/games/hashi/, in Hashi’s look (game="hashi" has pages).
  2. He submits. signup creates the account, signs him in with Django’s ModelBackend (named explicitly, since two backends are configured), counts a signup event for hashi, adds “Welcome, Ray.” and redirects to /games/hashi/.
  3. That response sets sessionid and messages. Hashi’s page now renders data-user and data-csrf (so it sets csrftoken) and shows the welcome as a toast.
  4. The solve he finished as a guest was kept in uo:hashi:pending, and the game can post it now.

The account#

accounts/models.pyUser · lines 44–69
class User(AbstractUser):
    """A player. The email is private (sign-in and password resets only); the
    display name is what the leaderboards show."""

    username = None
    first_name = None
    last_name = None
    email = models.EmailField("email address", unique=True)
    display_name = models.CharField(max_length=24, help_text="Shown on the leaderboards. 3–24 characters.")
    # A player's house rules (docs/adr/0021): {game slug: that game's settings},
    # as the game's own rules cleaned them. One blob rather than a table per
    # game, so a new table game needs no migration; it goes with the account.
    house_rules = models.JSONField(default=dict, blank=True)
    # The games whose How to play has opened by itself for this player
    # (docs/adr/0039): [game slug], so it opens once wherever they sign in.
    howto_seen = models.JSONField(default=list, blank=True)

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = []
    objects = UserManager()

    class Meta:
        constraints = [models.UniqueConstraint(Lower("display_name"), name="unique_display_name_ci")]

    def __str__(self):
        return self.display_name or self.email

There’s no username. You sign in with your email, which is private (used only for sign-in and password resets) and stored in lower case. The public name is display_name: unique ignoring case at the database (unique_display_name_ci), 3 to 24 characters matching ^[A-Za-z0-9][A-Za-z0-9 _.\-']{1,22}[A-Za-z0-9.]$. One name is reserved:

accounts/forms.pyclean_display_name · lines 12–24
def clean_display_name(value, exclude_pk=None):
    value = " ".join(value.split())
    if not NAME_RE.match(value):
        raise forms.ValidationError("Use 3–24 letters, numbers, spaces, or . _ - '")
    # What card history calls a deleted account's games (docs/adr/0010).
    if value.casefold() == "former player":
        raise forms.ValidationError("That name is taken. Try another.")
    taken = User.objects.filter(display_name__iexact=value)
    if exclude_pk:
        taken = taken.exclude(pk=exclude_pk)
    if taken.exists():
        raise forms.ValidationError("That name is taken. Try another.")
    return value

“Former player” is what table history calls a deleted account, so no living account may take it. Changing your display name renames you everywhere at once, past games included, because a kept seat shows its account’s current name (ResultSeat.shown), not the name it had then.

  • Passwords need at least 10 characters and can’t be too like your email, a common password, or all numbers (Django’s four validators).
  • house_rules maps a game slug to that game’s settings. The game’s own options() cleans them on the way in and out, so a setting a game has dropped quietly disappears.
  • howto_seen lists the games whose How to play has opened by itself for this player. Every page’s head passes it to the browser (<meta name="uo-howto">, from the lazy howto context processor, never on the guide). The house kit’s games/static/games/house/howto.js reads it beside each game’s own browser key, so the rules open once per player, not per device (ADR 0039).

Sign-in ignores the email’s case. Sign-up lower-cases the address and refuses one matching an existing account in any case, but an older or admin-made account may have capitals. So the user manager’s lookup (get_by_natural_key, which ModelBackend calls) tries an exact match, then a case-insensitive one:

accounts/models.pyUserManager.get_by_natural_key · lines 19–30
def get_by_natural_key(self, email):
    """Sign-in ignores the case of the email. Emails are stored in lower case,
    but an account made before that (or in the admin) may have capitals, so
    an exact match wins and any other case is found too."""
    try:
        return self.get(email=email)
    except self.model.DoesNotExist:
        pass
    try:
        return self.get(email__iexact=email)
    except self.model.MultipleObjectsReturned:  # two spellings of one address, neither typed exactly: sign in with the real one
        raise self.model.DoesNotExist from None

If two stored spellings of one address ever existed and neither was typed exactly, the lookup finds neither, since it can’t tell which is meant; the player signs in with the account’s exact spelling. Password reset matches addresses ignoring case too (Django’s form always has).

Signing in, and being locked out#

Sign-in is Django’s LoginView with the site’s words (SignInForm). django-axes throttles repeated failures:

Setting Value Meaning
AXES_FAILURE_LIMIT 5 wrong passwords before a lockout
AXES_COOLOFF_TIME 1 hours a lockout lasts (axes reads a whole number as hours)
AXES_LOCKOUT_PARAMETERS username and IP address, together the pair that’s locked, not the account everywhere or the address for everyone
AXES_USERNAME_FORM_FIELD username Django’s sign-in form calls the email field username
AXES_USERNAME_CALLABLE accounts.lockout.username what a failure is counted against: the email typed, trimmed and lower-cased
AXES_LOCKOUT_TEMPLATE accounts/locked.html what a locked-out player sees

So five wrong passwords for one account from one address pause sign-in to that account, from that address, for an hour. Other addresses and other accounts aren’t paused. The email is lower-cased because sign-in ignores case; otherwise each spelling (Ray@x.com, ray@x.com) would give a guesser five more tries. LockoutTests in tests/test_accounts.py checks all three.

AxesStandaloneBackend comes first in AUTHENTICATION_BACKENDS, so a locked pair is refused before the password is checked. AxesMiddleware, last in the middleware, turns the refusal into the lockout page, which extends look.pages: a lockout on Euchre’s sign-in says “Too many tries” in the card room’s look, with a link to reset the password.

Two other things count toward the limit:

  • Deleting your account asks for your password and checks it with authenticate(), rather than comparing hashes itself, so wrong guesses there count too:
accounts/forms.pyDeleteForm.clean_password · lines 98–103
def clean_password(self):
    user = self.request.user
    found = authenticate(self.request, username=user.email, password=self.cleaned_data["password"])
    if found is None or found.pk != user.pk:
        raise forms.ValidationError("That isn't your password.")
    return self.cleaned_data["password"]
  • AXES_RESET_ON_SUCCESS is left at its default (off), so a correct password doesn’t wipe earlier failures. They age out with the cool-off.

Axes gets the client’s address from obstacles/client.py (AXES_CLIENT_IP_CALLABLE):

obstacles/client.pyclient_ip · lines 15–20
def client_ip(request) -> str:
    if settings.BEHIND_ROUTER:
        hops = [h.strip() for h in request.META.get("HTTP_X_FORWARDED_FOR", "").split(",") if h.strip()]
        if hops:
            return hops[-1]
    return request.META.get("REMOTE_ADDR", "")

On Heroku every request comes through the router, so REMOTE_ADDR is the router’s, the same for everyone (gunicorn’s uvicorn workers trust forwarded headers only from 127.0.0.1, and FORWARDED_ALLOW_IPS isn’t set). The router appends the connecting address as the last entry of X-Forwarded-For; earlier entries are whatever the client sent. So only the last is believed, and only on a dyno (BEHIND_ROUTER, from Heroku’s DYNO). Without this, the lockout would be per account for everyone, and a stranger could pause anyone’s sign-in for an hour. The Register’s case warming (Analytics) limits by the same address. LockoutTests puts a pretend router in front: five wrong passwords lock that address, a spoofed first hop changes nothing, and another player behind the router still signs in.

What axes keeps#

django-axes records each failed attempt (AccessAttempt: the username typed, IP address, user agent, path, and form data with the password masked) and each sign-in (AccessLog: username, IP address, user agent, times) in its own tables. They’re the one place the server keeps IP addresses. They hold the email as text, not a foreign key, so deleting an account doesn’t remove them; axes_reset and axes_reset_logs clear them.

Password reset emails#

The four reset pages are Django’s own views with the site’s templates, at both addresses like every account page:

  1. Ask. PasswordReset takes an email and sends the reset only if an active account has it. Either way the next page says “If there’s an account with that address, a link … is on its way,” so the form can’t reveal who has an account. (Sign-up can’t keep that secret: it has to say an email’s taken.)
  2. The email. It’s rendered without a request, so the view passes the look itself, extra_email_context = {"game": …}, blank on the hub. The text template (accounts/templates/accounts/reset_email.txt) builds the link from it, so a reset asked for on Hashi’s page opens Hashi’s reset page, and old Register reset links still land in The Register.
  3. Send. With RESEND_API_KEY set, mail goes over SMTP to Resend (smtp.resend.com, port 587, TLS). Without it, Django’s console backend prints the email, which is how it works locally.
  4. Choose a new password. The link carries Django’s standard token: an HMAC, keyed by the secret key, over the account’s id, password hash, last sign-in, email and a timestamp. It stops working once used (the password hash changes), once you sign in, or after Django’s default three days.

Changing your password while signed in (/account/password/) is Django’s PasswordChangeView, which keeps you signed in on the current device.

Feedback#

A signed-in player can write to the owner from any page except the guide’s, via “Feedback” in every footer or the last section of any game’s Options. It’s emailed to the owner when they press Submit, and the server keeps none of it (ADR 0038). A guest never sees it, and the endpoint refuses them.

One tag in the page’s head offers Feedback, with the address to post to and the CSRF token. The same lines load the house kit’s feedback.js and wire every footer’s “Feedback” to its sheet:

templates/house/app_head.htmlfeedback · lines 30–31
{% if feedback %}<meta name="uo-feedback" content="{% url 'feedback:send' %}" data-csrf="{{ csrf_token }}">
<script type="module">import { listen } from "{% get_static_prefix %}games/house/feedback.js"; listen();</script>{% endif %}

Whether it’s there is feedback, from a context processor:

feedback/context_processors.pyfeedback · lines 16–27
def feedback(request):
    """`feedback`: true for a signed-in player on a page that offers it.

    Lazy (a callable, which the template calls only where it asks), so a
    page that never asks never reads the session. The technical guide never
    offers it: its pages are public, cached and cookie-free for everyone
    (docs/adr/0035), so they carry nobody's button or CSRF token, and they
    say so without reading the session at all."""
    match = getattr(request, "resolver_match", None)
    if (match and match.namespace == "guide") or request.path.startswith("/guide/"):
        return {"feedback": False}
    return {"feedback": lambda: available() and request.user.is_authenticated}

It’s true only when all three hold:

  • the player is signed in;
  • feedback can go somewhere (available(): always locally, elsewhere only with FEEDBACK_TO set), so no page offers a button that could only say “can’t be sent”;
  • the page isn’t in the guide. Guide pages are public, cached for five minutes and the same for everyone (ADR 0035). They decide this from the address alone, without reading the session, so they carry no button or token, send no cookie and add no Vary: Cookie.

The sheet (the front-end chapter) posts the words, the page’s address and, if the box is ticked, a JPEG of the page to /feedback/:

feedback/views.pysend · lines 52–104
@require_POST
def send(request):
    """Email one piece of feedback to the owner: the player's words, who
    they are (their display name and, as reply-to, their address), the page
    they were on, their browser and the time, and the screenshot if they
    chose to include one."""
    user = request.user
    # (a JSON refusal rather than login_required's redirect: the sheet reads it)
    if not user.is_authenticated:
        return _no("Sign in to send feedback.", 403)
    # (feedback.middleware turns a post this big away before CSRF reads it; this
    # is the same rule for a request that reaches the view some other way)
    if int(request.META.get("CONTENT_LENGTH") or 0) > MAX_BODY:
        return _no("That’s too big to send. Try without the screenshot.", 413)
    text = (request.POST.get("text") or "").strip()
    if not text:
        return _no("Write something first.", 400)
    if len(text) > MAX_TEXT:
        return _no(f"That’s a bit long: {MAX_TEXT:,} characters at most.", 413)
    shot = request.FILES.get("screenshot")
    if shot is not None and (shot.size > MAX_SHOT or shot.content_type != "image/jpeg"):
        return _no("That screenshot is too big to send. Try without it.", 413)
    recent = _recent(user.pk)
    if len(recent) >= PER_HOUR:
        return _no("That’s plenty for one hour. Try again later.", 429)
    to = recipients()
    if not to:
        if not settings.DEV:
            log.warning("Feedback from user %s not sent: FEEDBACK_TO isn't set.", user.pk)
            return _no("Feedback can’t be sent just now. Try again later.", 503)
        to = [DEV_TO]

    page = (request.POST.get("page") or "")[:500]
    agent = request.META.get("HTTP_USER_AGENT", "")[:500]
    at = timezone.localtime()
    body = "\n".join([
        text, "", "—",
        f"From: {user.display_name} (user {user.pk})",
        f"Page: {page or 'unknown'}",
        f"Browser: {agent or 'unknown'}",
        f"Time: {at:%Y-%m-%d %H:%M} {at.tzname()}",
        f"Screenshot: {'attached' if shot else 'none'}",
    ])
    message = EmailMessage(f"Feedback from {user.display_name}", body, settings.DEFAULT_FROM_EMAIL, to, reply_to=[user.email])
    if shot is not None:
        message.attach("screenshot.jpg", shot.read(), "image/jpeg")
    try:
        message.send()
    except Exception:  # (a mail server's trouble is said kindly; the log has the rest)
        log.exception("Feedback from user %s not sent.", user.pk)
        return _no("Feedback can’t be sent just now. Try again later.", 503)
    cache.set(f"feedback:{user.pk}", recent + [time.time()], HOUR)
    return JsonResponse({"ok": True, "screenshot": shot is not None})

Its limits:

Name Value What it is
MAX_TEXT 4,000 Characters at most.
MAX_SHOT 2,097,152 Bytes in a screenshot at most (the page aims under 1.5 MB).
MAX_BODY 2,162,688 Bytes in the whole post.
PER_HOUR 10 Sends a player may make in an hour.

The post’s size is checked twice. CSRF checking reads a POST’s whole form, screenshot included, before any view runs, so a small middleware ahead of it (feedback/middleware.py) turns away a post whose Content-Length is over MAX_BODY unread. The view checks the size again, then the text’s length and the screenshot’s size and type. A chunked post with no Content-Length isn’t caught early, but the view still refuses it.

It’s one EmailMessage from DEFAULT_FROM_EMAIL to FEEDBACK_TO, the owner’s address from the environment (comma-separated for several; never in the repo), with reply-to set to the player’s email and the screenshot attached as screenshot.jpg. The body is the words, then the player (display name and account id), the page, the user agent and the time. It uses the password-reset backend (Resend in production, the console locally) and gives up kindly after EMAIL_TIMEOUT seconds (10). With FEEDBACK_TO unset outside development, no page offers Feedback, and a post that arrives anyway is refused with “can’t be sent just now” while the log says why.

The rate limit is a list of send times per player in Django’s cache: a ceiling against a stuck button, not a record. It’s per process, so a few workers allow a few times as many. Refused sends don’t count.

tests/test_feedback.py covers all of it, including that no page shows Feedback to a guest or when there’s nowhere to send it, and that a guide page is byte for byte the same for a signed-in player as for a guest, with no cookie, no token and Cache-Control: public.

Your account page#

/account/ is in the hub’s look: who you’re signed in as, your ratings, “Your games”, your display name, password change, sign-out and account deletion. The hub knows nothing about any game. Each game says what it keeps for you through its catalog entry’s account_summary, a dotted path to a function (user, game):

games/catalog.pyGame.summary · lines 93–103
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 contract is small:

  • Return {"lines": [...], "link": (label, url)}, or None when there’s nothing yet (the card then says “Not played yet”).
  • Stay cheap: a query or two of aggregates, never one per game played, because the page asks every listed game.
  • Never take the page down. An exception is logged and the card shows UNAVAILABLE.

Every table game shares one:

tables/history.pyaccount_summary · lines 147–159
def account_summary(user, game) -> dict | None:
    """A card game on the hub's account page (docs/adr/0011): the history
    page's totals (games, wins, average score), counted in one query."""
    t = ResultSeat.objects.filter(user=user, result__game=game.slug).aggregate(
        played=Count("id"), wins=Count("id", filter=Q(place=1)), average=Avg("total"))
    if not t["played"]:
        return None
    played, wins = t["played"], t["wins"]
    return {
        "lines": [f"{played:,} game{'s' if played != 1 else ''} · {wins:,} win{'s' if wins != 1 else ''}",
                  f"Average score {round(t['average']):,}"],
        "link": ("Your games", reverse("history:games", args=[game.slug])),
    }

The profile view puts the cards together and lists your ratings the same defensive way:

accounts/views.pyprofile · lines 103–116
def profile(request, game):
    """Your account, on the hub: your display name, password and the way to
    delete it all, with a card for each game saying what it keeps for you
    (each game's `account_summary`, so the hub knows nothing about them)."""
    form = ProfileForm(request.POST or None, instance=request.user)
    if request.method == "POST" and form.is_valid():
        form.save()
        messages.success(request, "Saved.")
        return redirect("accounts:profile")
    games = [(g, g.summary(request.user)) for g in listed()]
    ratings = _ratings(request.user)
    # (nothing kept in any game yet: the page points somewhere to begin)
    fresh = not ratings and not any(summary for _, summary in games)
    return render(request, "accounts/profile.html", {"form": form, "games": games, "ratings": ratings, "fresh": fresh})

A game with no account_summary keeps nothing for players and shows its tagline and a Play link.

A brand-new account has no summaries and no ratings, so fresh is true, and “Your games” offers one way to begin, the hub’s card to the Dailies page (“Start with today’s dailies”), instead of a row of “Not played yet”. It goes for good once any game keeps something. Signing up on the hub lands here, under “Welcome, Agatha.”.

Deleting an account#

You can delete your own account from /account/, with your password. Staff deleting an account in the admin does exactly the same, because the work is in the database’s foreign keys and one signal, not the view:

accounts/views.pydelete · lines 135–145
def delete(request, game):
    """Close your own account. Its card history goes (games it shared with
    other players keep them, as "Former player"), and so do its leaderboard
    scores (docs/adr/0010)."""
    form = DeleteForm(request, request.POST or None)
    if request.method == "POST" and form.is_valid():
        user = request.user
        logout(request)
        user.delete()
        return redirect(account("deleted", game))
    return render(request, "accounts/delete.html", {"form": form})

The view signs you out, then deletes the User. Everything that pointed at it:

What on_delete So
The Register’s Score; Solve in Hashi, the kit, Solitaire, Chess Puzzles; Solitaire’s Loss; Chess Puzzles’ Miss CASCADE gone, and off every leaderboard
Rating, Change CASCADE your ratings and their history, gone
House rules, How to play seen (on the User row) gone with it
Seat at a live table SET_NULL, renamed by the signal the seat plays on as “Former player”
ResultSeat in kept games SET_NULL, renamed and marked by the signal “Former player” in other people’s history
analytics.Event SET_NULL the count stays, with no account

The signal does what a foreign key can’t. It runs before the delete, while the account’s rows can still be found:

tables/signals.pyforget_player · lines 13–20
@receiver(pre_delete, sender=settings.AUTH_USER_MODEL)
def forget_player(sender, instance, **kwargs):
    # A seat still at a table is renamed too, so a game finished later
    # doesn't keep the name.
    Seat.objects.filter(user=instance).update(user=None, name=ResultSeat.FORMER)
    theirs = list(ResultSeat.objects.filter(user=instance).values_list("result_id", flat=True))
    ResultSeat.objects.filter(user=instance).update(user=None, name=ResultSeat.FORMER, former=True)
    Result.objects.filter(pk__in=theirs).exclude(seats__user__isnull=False).delete()

It does three things:

  • renames your seats at any open table, so a game that finishes later doesn’t keep your name;
  • turns your kept seats into “Former player” with former=True, so they read as a former player, not a guest;
  • deletes every kept game with no signed-in player left. A game is in history only for the signed-in players who played it; with none left, there’s no one to keep it for.

A worked example. Ray has played three Euchre games: one with Mo (signed in) and two guests, one with Mo and bots, and one alone against bots. When he deletes his account, the first two Results stay, since Mo’s seat is linked, and Ray’s seat in them reads “Former player”, with his scores. The third had no other account, so it’s deleted. His Hashi solves, ratings and house rules go with his User row; his analytics events remain, unnamed. Other players’ ratings aren’t recalculated: what they gained or lost against him stands.

The delete page lists all of it: “your Register leaderboard scores, your Hashi and puzzle times, your Solitaire wins, your Chess Puzzles solves, your ratings and your table game history”.

Analytics#

The site counts, in its own database, whether people play: pages opened, puzzles started and solved, tables started and finished, accounts made, results posted. No third party sees anything, no cookie is set for it, and no consent banner is needed (ADR 0005).

An event#

analytics/models.pyEvent · lines 22–36
class Event(models.Model):
    at = models.DateTimeField(auto_now_add=True, db_index=True)
    kind = models.CharField(max_length=20, choices=KINDS.items(), db_index=True)
    game = models.CharField(max_length=40, blank=True)
    mode = models.CharField(max_length=12, blank=True)
    difficulty = models.CharField(max_length=12, blank=True)
    daily = models.BooleanField(default=False)
    user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL)
    visitor = models.CharField(max_length=32, blank=True, help_text="A random id the game keeps in the browser; not a cookie, not personal.")

    class Meta:
        ordering = ["-at"]

    def __str__(self):
        return f"{self.at:%Y-%m-%d %H:%M} {self.kind} {self.game}"

That’s every field. The kinds:

KindMeans
visitOpened a game
case_startedStarted a case
case_solvedSolved a case
puzzle_startedStarted a puzzle
puzzle_solvedSolved a puzzle
signupCreated an account
score_submittedPosted a score
table_startedStarted a card table
table_finishedFinished a card game
Live from analytics/models.py, KINDS

mode and difficulty are the game’s own words for what was played: a Register mode and difficulty, a Hashi size like 10, a Solitaire board like klondike-3, a kit puzzle’s size and difficulty, a Chess Puzzles band, or solo or together for a table. daily marks the day’s daily.

Recording one#

On the server it’s one function:

analytics/models.pyrecord · lines 39–41
def record(request, kind, game="", mode="", difficulty="", daily=False, visitor=""):
    user = request.user if getattr(request, "user", None) and request.user.is_authenticated else None
    Event.objects.create(kind=kind, game=game, mode=mode, difficulty=difficulty, daily=daily, user=user, visitor=visitor[:32])

record() attaches an account only when the request carries a signed-in player’s session cookie. So a table’s events never have one: create is called with credentials: 'omit', and the table service writes table_finished with no account, since a bot’s move can end a game with no request at all.

Kind Recorded by
visit games.views.play when a game’s page is served; the Dailies page as game dailies
signup accounts.views.signup
score_submitted each game’s posting API, once the referee has accepted the result
table_started tables.views.create
table_finished tables.service, when the rules say a game is over
case_started, case_solved, puzzle_started, puzzle_solved the browser, through the beacon below

The beacon and the per-browser id#

Starts and finishes happen in the browser, so solo games report them with navigator.sendBeacon (falling back to a keepalive fetch) to POST /api/events/. To count players rather than plays, each report carries a random id the browser makes once and keeps:

games/static/games/the-register/js/hub.jsvisitor · lines 22–32
/** A random id this browser keeps so the hub can count players, not plays. Not a cookie; nothing about the person. */
function visitor() {
  try {
    let v = localStorage.getItem('uo:visitor');
    if (!v) {
      v = [...crypto.getRandomValues(new Uint8Array(8))].map(b => b.toString(16).padStart(2, '0')).join('');
      localStorage.setItem('uo:visitor', v);
    }
    return v;
  } catch { return ''; }
}

It’s eight random bytes as sixteen hex characters, kept as uo:visitor: not a cookie, derived from nothing, sent only in these reports. It lets Analytics say “12 players” instead of “40 puzzles started” without saying who. One person on two devices counts as two.

The endpoint can only add a counted event, never read or change anything. That’s why it’s csrf_exempt: a beacon can’t carry a CSRF header. It checks everything it’s sent:

  • the game must be one it knows, and the kind a start or a finish;
  • the mode must be one of that game’s sizes, boards or bands, or it’s left blank;
  • the id must match ^[a-f0-9]{8,32}$, or it’s dropped.

A Register case start also names the case. The endpoint hands it to the referee to check in the background, before the player finishes, for the engine the server runs. That’s capped at 60 an hour per address, since the endpoint is anonymous and each check is a Node run (Referees). A beacon does carry cookies, so a signed-in player’s starts and finishes are recorded with their account, as ADR 0005 says.

Analytics, the owner’s page#

/analytics/ is staff-only (staff_member_required). Staff find it as a quiet “Analytics” link in every Django page’s footer, after Feedback (templates/house/admin.html); no one else sees it. It was /stats/ until ADR 0040, too easily taken for a player’s Stats tab, and that address now redirects. It shows the last 30 days:

  • Who’s about, as charts drawn by static/js/analytics.js: players each day, signed in (distinct accounts) and guests (distinct browser ids on events with no account); new accounts by the day they joined; every event by hour (Central); and players by game.
  • Players: every account seen in those days, most recent first (at most 100). For each: joined, last seen, active days, events and games, all-time kept results (posted solves, Solitaire’s wins and losses, Chess Puzzles’ misses, kept table games), their main game, and a small line of their days. Guests are never listed, since a browser id is all there is of them.
  • Counts: day by day, every game on one line, each puzzle and Solitaire board by size, and totals of accounts and kept results.

Each player’s name opens /analytics/players/<id>/: their days over the last 90 days, their hours, each game they’ve touched (visits, starts, finishes, results kept, rating, last there) and their latest 50 events. Both pages read only what the site already keeps (Event rows with an account, and the games’ results; analytics/players.py). Nothing new is recorded for them. Raw rows stay in the admin, filterable by kind, game and day.

What’s never recorded#

Nothing in Event can identify someone who hasn’t signed in: no IP address, user agent, referrer, page address, table code, name typed at a table or cookie. A spectator’s watch id isn’t recorded at all, and crowd answers only as a count per answer. The one exception is the one you opt into by signing in: your events then carry your account, and deleting the account removes that link. Feedback isn’t an event; it’s a message you send, emailed and not kept (above). The sign-in records django-axes keeps are security records, not analytics.

If events ever did identify people, ADR 0005 asks that the game say so first.