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.
On this page
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.js keep · 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 untillogin(). It’sHttpOnly,Securein 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.js call · lines 65–81async 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.py PAGES · lines 20–31PAGES = [
("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.py urlpatterns · lines 33–39urlpatterns = [
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.py in_game · lines 29–36def 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.py look · lines 4–11def 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.py account · lines 39–41def 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.py account_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.py signed_in · lines 48–56def 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.
- He lands on
/games/hashi/account/signup/?next=/games/hashi/, in Hashi’s look (game="hashi"haspages). - He submits.
signupcreates the account, signs him in with Django’sModelBackend(named explicitly, since two backends are configured), counts asignupevent forhashi, adds “Welcome, Ray.” and redirects to/games/hashi/. - That response sets
sessionidandmessages. Hashi’s page now rendersdata-useranddata-csrf(so it setscsrftoken) and shows the welcome as a toast. - The solve he finished as a guest was kept in
uo:hashi:pending, and the game can post it now.
The account#
accounts/models.py User · lines 44–69class 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.py clean_display_name · lines 12–24def 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_rulesmaps a game slug to that game’s settings. The game’s ownoptions()cleans them on the way in and out, so a setting a game has dropped quietly disappears.howto_seenlists 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 lazyhowtocontext processor, never on the guide). The house kit’sgames/static/games/house/howto.jsreads 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.py UserManager.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.py DeleteForm.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_SUCCESSis 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.py client_ip · lines 15–20def 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:
- Ask.
PasswordResettakes 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.) - 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. - Send. With
RESEND_API_KEYset, 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. - 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.html feedback · 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.py feedback · lines 16–27def 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 withFEEDBACK_TOset), 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.py send · 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.py Game.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)}, orNonewhen 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.py account_summary · lines 147–159def 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.py profile · lines 103–116def 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.py delete · lines 135–145def 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.py forget_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.py Event · lines 22–36class 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:
| Kind | Means |
|---|---|
| visit | Opened a game |
| case_started | Started a case |
| case_solved | Solved a case |
| puzzle_started | Started a puzzle |
| puzzle_solved | Solved a puzzle |
| signup | Created an account |
| score_submitted | Posted a score |
| table_started | Started a card table |
| table_finished | Finished a card game |
analytics/models.py, KINDSmode 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.py record · lines 39–41def 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.js visitor · 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.