A self-hosted webcam table for playing Commander (Magic: The Gathering) remotely with your playgroup. Everyone points a camera at their board, clicks any card on any board to identify it, and tracks life, commander damage, turns, and counters together; when the game ends, the result lands in your group's game history and stats. One container, one SQLite file, and no hosted services required.
- Webcam table: rooms for two to ten players over a WebRTC SFU built into the server, with shared life, commander damage, poison, commander tax, monarch, turns and per-player clocks, dice rolls, a shared log, spectators, private hand reveals, and Commander, Two-Headed Giant, Five Star, and Duel Commander modes, plus unrecorded one-on-one Standard, Pauper, and Canadian Highlander games.
- Card recognition: click a card on any board and the browser identifies it with Oracle's models, then shows its printing, rules text, and rulings to the whole table, favouring cards in that player's linked deck list.
- Virtual board: play from a deck's linked list on a virtual tabletop instead of a camera.
- AI rulings: ask an AI judge rules questions at the table; it looks up the current Comprehensive Rules and each card's Oracle text and rulings, and cites the rules it used.
- Discord: member sign-in,
/newgamequeues that open a table once enough players join, SpellBot game tracking, and/logand/summarycommands. - Game history and stats: results recorded from the table, logged by hand, or imported from CSV or Mythic Track, with kills, MVP cards, and playgroup, player, commander, color, and deck statistics.
- Decks: Moxfield, Archidekt, and ManaVault deck links and syncing, a local Scryfall card catalog, and a weighted deck chooser members can script in Lua.
Games → Play opens a temporary room for up to ten signed-in members who share their
board cameras through a WebRTC SFU built into the server (each browser uploads one simulcast
stream and receives every other board at the resolution it is drawn) and record the result
when the game ends. The SFU listens on the UDP range WEBRTC_SFU_PORT_RANGE; forward it to the
host and set WEBRTC_SFU_PUBLIC_IP, or set WEBRTC_SFU_RELAY_ONLY=true to carry media over the
configured TURN servers instead (see docs/webcam-table.md).
Reveal hand to privately sends your camera to one chosen player; everyone else receives no video until you end the reveal or the target leaves.
Clicking a card on any board identifies it with the recognizer bundle published from
Oracle (see its README, "Shipping"): the server downloads the newest scanner-bundle-* release of this
repository at boot and every six hours (CARDID_BUNDLE_SOURCE), serves DATA_DIR/cardid/current/*
at /api/cardid/*, and the browser runs the models itself. A recognized card opens with its rules text and
rulings (from the local catalog) and lands in that board's card tray for every seat.
Without a published bundle the table still works and offers the player's commanders as
suggestions instead.
When a seated player's chosen deck links to a list, every seat loads it and warms its card images, Decks → View decklist shows your own list, and the recognizer favours cards in the clicked board owner's list (see Oracle's README, "Deck-list prior"). Opponents' lists are not shown in the UI but are visible in the browser's network tab.
Use virtual board in your seat menu replaces your camera with a virtual tabletop dealt from your deck's linked list; other players see the board, with your hand and library as counts.
Trackers over your own camera keep free-form 0–100 counters ("Lands", "Creatures in graveyard") and a combat-math list: enter each anthem or combat buff with its conditions (attacking, blocking, flying, tokens, nontoken, or any creature type) and the overlay shows the combined bonus for every kind of creature ("Attacking token creatures +4/+4 · vigilance"). Each counter and the buff list has its own "Show to table" switch; shared ones appear on your seat for everyone and shared counter changes are logged, while private ones stay in your browser.
Rules asks an AI judge for a ruling. The server sends the question, the earlier turns of the conversation, and (when the player ticks the box) the commanders and identified cards at the table to a model on OpenRouter, which answers with tools that search and read the Magic Comprehensive Rules and look cards up in the local catalog (current Oracle text plus Scryfall's rulings). Answers cite rule numbers, show the cited rules' text, and preview named cards on hover. The conversation stays in the browser tab. An administrator enables it under Admin → Settings → AI rulings with an OpenRouter API key (stored encrypted) and any model ID that supports tool calling; saving checks both with OpenRouter. Every question's cost, as OpenRouter reports it, is recorded per member: set Monthly limit per member (USD) to cap each member's spending per calendar month (UTC); administrators are not limited. This month's spending per member is on the same page. Questions are also limited to 30 per member per 10 minutes. Requests mark prompt-cache breakpoints and keep each conversation on one provider, so models that support prompt caching bill the repeated system prompt, tools, and earlier lookups at their cheaper cached rate; the cached token counts are recorded with each question's cost.
The server downloads the Comprehensive Rules from the text file linked on
magic.wizards.com/en/rules shortly after boot, then checks that
page daily and installs each new release (RULES_SYNC_ENABLED=false turns the checks off). Admin →
Catalog → Comprehensive Rules shows the installed release and checks now.
Design notes are in docs/webcam-table.md; to improve recognition or contribute training data, see Oracle's CONTRIBUTING.md.
Use /newgame [start] [min_players] [title] [format] to post a public Join/Maybe/Leave
queue. The default minimum is three (configurable from two to ten). Without
start, it opens a webcam-table link as soon as the minimum joins; scheduled
queues start or expire at the requested time. Maybe doesn't count toward the
minimum, but if a scheduled game is short at its start time the bot pings the
maybe list and waits 15 minutes for them to join. The host or a Discord Administrator
can Change time or Cancel. The queue and its buttons live in a thread on the
/newgame reply, where the ready and maybe pings land too; the host or an Administrator runs /close in it to
close it. See Discord integration
for time syntax, permissions, and restart/delivery behavior.
When logging or editing a game, use each player's Kills dropdown to select who they killed. Total kills includes those selections; enter a larger total (or only a total) when some names are unknown. Leave it blank when the total is unknown; zero means no kills. Game details show named victims and any unknown remainder. A victim can have only one killer. Totals, including CSV corrections, cannot be lower than the number of named victims. The live table's Record game result modal also offers optional kills and MVP cards for each player, saved with either Record result or Record and rematch. Player rivalries show who you killed most, who killed you most, and directional kill counts for each opponent in the selected date range. These count named victims only; number-only kills stay unattributed, and win/loss rivalry rankings are unchanged. On player profiles, rivalry cards and head-to-head counts open the matching games, retaining the date range. Commander rivalry counts also link to games with that opposing commander; mirror matches can contribute multiple seats to a count, but the list shows each game once.
On upgrade, existing games with a sole winner who recorded exactly one kill per opponent are attributed to that winner, unless existing attribution contradicts it. Other totals remain unchanged and unattributed. This inference runs once on historical games, not on new count-only entries. Portable exports retain both totals and attribution.
Requirements: Docker with Compose.
-
Create a directory and download
docker-compose.ymland.env.exampleinto it. -
Copy
.env.exampleto.envand setTHE_GATHERING_SECRET_KEY:openssl rand -base64 64 | tr -d '\n'
Set
THE_GATHERING_PUBLIC_URLto the address users reach the app at (for examplehttps://games.example.combehind a reverse proxy). -
Start it:
docker compose up -d
The app listens on port 4000 and stores its SQLite database and files under ./data (mounted at /data). Back up that directory to back up everything. TLS is left to your reverse proxy.
When THE_GATHERING_PUBLIC_URL is an https:// address (other than localhost), the session cookie is marked Secure, so browsers send it only over HTTPS and signing in over plain http://<host>:4000 stops working; use the public address. HSTS (Strict-Transport-Security) belongs on the reverse proxy that terminates TLS, so enable it there, and keep port 4000 off the public network.
Health check: GET /api/health returns {"status":"ok"} when the database is reachable.
Administration → Server settings → Software update shows the running version and whether
GitHub has a newer one (the latest release for vX.Y.Z builds, the newest main commit for
nightly-<commit> builds, the preview tag's commit for preview-<commit> builds; the check is
cached for 15 minutes). The Update now button installs
the newest build of the channel the server already follows; it does not switch channels. The app
never replaces itself, it asks whatever runs it:
- Proxmox LXC: the installer (and every
updaterun) sets up athe-gathering-update.pathsystemd unit that runsupdateas root when the app writes/var/lib/the-gathering/update-request. Containers created before this existed get it the next timeupdateruns. - Docker: an optional Watchtower sidecar. In
.envsetCOMPOSE_PROFILES=self-updateandWATCHTOWER_HTTP_API_TOKEN(openssl rand -hex 32), thendocker compose up -d. The button then asks Watchtower to pull the image tag the container was started from (latestfollowsmain; pin0.2or av0.2.0tag for releases) and recreate the container. Watchtower does not poll on its own in this setup.
When neither is configured the section only shows the version and says the server has to be
updated by hand. The version comes from priv/VERSION, which the release and container builds
write; development builds have none.
deploy/proxmox/the-gathering.sh creates an unprivileged
Debian LXC that runs the app natively (no Docker): it installs the release tarball (the Rust server and the built web app) from the
latest GitHub release into
/opt/the-gathering, keeps data in /var/lib/the-gathering, reads settings from
/etc/the-gathering.env (same keys as .env.example), and runs it as the the-gathering
systemd service. Run it as root on the Proxmox host. It asks for the public URL and the first
administrator account; container settings such as CTID, STORAGE, IP, CORES, RAM_MB and
VERSION are environment variables documented at the top of the script. By default it takes the
next free container id, lets DHCP assign an address and then stores that address as the
container's static IP (PIN_IP=false keeps DHCP), and uses the Proxmox host's time zone
(TIMEZONE).
bash -c "$(curl -fsSL https://raw.githubusercontent.com/cfbender/the-gathering/main/deploy/proxmox/the-gathering.sh)"Updates work like the community-scripts helpers: inside the container, update [tag] is on PATH
and installs the latest (or given) release and restarts the service (it runs the current copy of
the script from GitHub, so fixes to the updater reach existing containers). From the Proxmox host,
bash the-gathering.sh update <CTID> [tag] does the same, as does Update now in the admin UI
(see above; it logs to journalctl -t the-gathering-update). The previous release stays under
/opt/the-gathering/releases/ for rollback. Release tarballs are built by
.github/workflows/release.yml for every v*.*.* tag, and every
push to main republishes the rolling
nightly prerelease. update nightly (or VERSION=nightly at install time) switches a container to that channel: from then on
untagged and automatic updates follow main, and update vX.Y.Z returns to tagged releases.
To try a branch before it is merged, run the Release workflow manually on that branch
(Actions → Release → Run workflow, or gh workflow run release.yml --ref <branch>). It republishes
the rolling preview prerelease
from that branch (version preview-<commit>) and never touches nightly or the latest release.
Install it with the branch's copy of the script, since main's may not know the channel yet:
bash -c "$(curl -fsSL https://raw.githubusercontent.com/cfbender/the-gathering/<branch>/deploy/proxmox/the-gathering.sh)" \
the-gathering.sh update <CTID> previewFrom then on untagged and automatic updates (and Update now) follow preview, using the
script at the preview tag; update vX.Y.Z or update nightly leaves the channel.
The container also updates itself on a cron schedule: AUTO_UPDATE (asked at install time;
default 0 4 * * *, daily at 04:00 in the container's time zone; off disables it) is written to
/etc/cron.d/the-gathering-update, and each run logs to journalctl -t the-gathering-update.
Change it later with bash the-gathering.sh auto-update <CTID> '<cron expression>' or
... auto-update <CTID> off (without the <CTID> when run inside the container). Containers
created before the time zone option existed run on UTC; pct set <CTID> --timezone host followed
by a restart moves them to the host's zone.
Under Administration → Server settings → Sign-up invitation, create a reusable Discord sign-up link. Copy and save it before leaving the page: only its hash is stored, so the secret cannot be retrieved later. Anyone with the link can join as a member even with Open registration off. Links do not expire or limit uses.
Rotate link generates a replacement and immediately revokes the old link's registration permission, including OAuth sign-ups that have not completed yet. Already registered users keep their access; disabled accounts remain blocked. Normal open registration and the initial password administrator setup are unchanged. Creating or rotating links requires administrator sudo authentication. The secret uses a URL fragment (not a logged request path or query), then a signed session carries its digest through Discord OAuth. Treat the full link as a credential.
POST /api/decklists/resolve accepts {"url":"..."} for a public Moxfield,
Archidekt, or self-hosted ManaVault deck (when MANAVAULT_URL is set). Successful responses contain the canonical
URL, deck name, commanders, commander color identity, author when exposed, card
count, and fetch timestamp under data. Successful lookups are cached in memory
for five minutes; errors are never cached.
GET /api/decks/:id/decklist returns the playable card list behind a deck's linked
Moxfield, Archidekt, or ManaVault page: each card's name, quantity, zone
(commander or mainboard; maybe-, side- and ManaVault "considering" boards are left
out), the exact Scryfall printing when the list records one, and type, mana cost and
cached images from the local catalog. It reuses the same live fetch and five-minute
cache; decks without a supported link, and missing or private lists, return 404. The
app keeps no copy of the list.
Each user can also save a Moxfield username, Archidekt username, and ManaVault
instance URL plus personal API key under Settings → Profile → Deck hosts.
PATCH /api/session/user accepts these as moxfield_username, archidekt_username,
manavault_url, and manavault_api_key. The API key is write-only: it is stored
encrypted with THE_GATHERING_SECRET_KEY (keys stored by 0.2 and earlier are re-encrypted in the
current format at startup), never returned (user JSON exposes only
has_manavault_api_key), a blank value keeps the saved key, and null removes it.
GET /api/session/remote-decks returns the signed-in user's normalized public decks
(name, commanders, color identity, URL, source, and upstream update time) plus a
status for each source. Results, including source errors, are cached in memory for
five minutes. The user's player page links these decks, and the new-game form can
resolve one into a new deck with a quick pick.
Settings → Appearance picks a color palette (Claret, Nord, Catppuccin, Tokyo Night,
Gruvbox, Everforest, Kanagawa, Night Owl, Dracula, Rosé Pine, Solarized, or Monochrome) and a surface style (Liquid glass or
Classic). Both are saved on the user account through PATCH /api/session/appearance
({"palette": "...", "theme_style": "glass" | "classic"}), returned in user
JSON, and rendered onto <html> by the SPA shell so the first paint already matches on
any device. Light/dark mode stays a per-device preference on the navigation toggle.
The integrations use the upstream services' public interfaces:
- Moxfield:
GET https://api2.moxfield.com/v3/decks/all/:id. The request sends a descriptive User-Agent, but Moxfield does not publish API limits or a supported third-party API contract and may reject server traffic with Cloudflare 403s. User listings use the unofficialGET https://api2.moxfield.com/v2/decks/search-sfwendpoint and report upstream blocking without failing the rest of the list. - Archidekt:
GET https://archidekt.com/api/decks/:id/. No authentication or documented public rate limit is currently required. User listings use the unofficialGET https://archidekt.com/api/decks/v3/?ownerUsername=...endpoint, then fetch public deck details to identify commanders. - ManaVault:
POST $MANAVAULT_URL/share/graphqlagainst the ManaVault instance you configure. No authentication is required; ManaVault's default limit is 120 requests per IP per minute. Author is not exposed by its public schema. Only the configured origin is recognized; other origins are intentionally not fetched to avoid SSRF. WithoutMANAVAULT_URL, ManaVault links are stored as plain deck links. Listing a user's ManaVault decks uses their own instance URL and personal API key (GET <instance>/api/v1/deckswithAuthorization: Bearer <key>, paginated withpage/per_page); the URL must be an HTTPS origin with no path, credentials, query, or fragment. Private, loopback, link-local, and Tailscale destinations are blocked unless the exact hostname is listed inMANAVAULT_ALLOWED_HOSTS; those explicitly trusted hosts may also use HTTP.MANAVAULT_ALLOW_INSECURE_URLS=truepermits HTTP for otherwise-public hosts without permitting private destinations. Redirects are not followed. The key is created under ManaVault's Settings → Personal API keys. Listings stop after 20 pages, 500 decks, 2 MB of responses, or 15 seconds per source and report truncation in that source's status. Because an authenticated listing may include unshared decks, quick picks from it prefill the deck form directly rather than re-resolving a public share link. Without a key, the source reports that one is needed and individual public share links continue to resolve normally.
Decks → Choose a deck picks from the signed-in user's linked player's active decks. Selection uses ManaVault's weighting: hours since last played, multiplied by current skips plus one, divided by recorded plays plus one. Never-played decks get a 30-day boost beyond the stalest played deck. Plays and last-played dates come from game seats; only the current skip count and per-deck chooser inclusion setting are stored. Skipping records the skip before rerolling, while choosing clears that deck's skips.
Members can replace that weighting with their own Lua script under Choose a deck →
Customize. A script defines weight(deck, ctx) and sees each deck's plays, skips,
results, recent games, and colors, plus the local time, games played today and tonight,
and the member's recent history. The editor previews every deck's chance under the draft
before saving. Scripts run sandboxed on the server with memory, instruction, and time
limits, and a script that fails falls back to the built-in weights for that pick. See
docs/deck-chooser-scripts.md for the contract, every field,
and the examples.
Hosted decks are not listed separately: Sync hosted decks (on your player profile
and the chooser, shown once any deck host is configured) calls POST /api/session/remote-decks/sync, which folds only decks explicitly marked as Commander
by Moxfield, Archidekt, or ManaVault into your player's deck list. Other formats and
decks with missing or unknown formats are skipped, even if they have commanders.
Previously imported decks are not removed, and individual deck-link imports are
unaffected. A remote deck matches the local deck
with the same deck-list URL or name, then an unlinked local deck with the same
commander pair; otherwise it is added as a new deck using the host's details.
Existing decks keep their local names, commanders, selected art, colors, archive
status, and chooser inclusion. Sync only adds a missing deck-list link; it never
re-points an existing link. The response's updated count includes all matched
existing decks, even when nothing changed. Hosts that fail to list are skipped and
reported in the response's errors.
| Variable | Default | Purpose |
|---|---|---|
THE_GATHERING_SECRET_KEY |
required | Encrypts the session cookie, socket tokens, stored ManaVault API keys, and the OpenRouter API key. At least 64 bytes; keep it when upgrading, or stored keys stop decrypting. Releases up to 0.2 called it SECRET_KEY_BASE, which still works and logs a warning. |
DATA_DIR |
/data |
Where the database, uploaded files, and the cardid/ recognizer bundles live. |
DATABASE_PATH |
$DATA_DIR/the_gathering.db |
SQLite database file. |
THE_GATHERING_PUBLIC_URL |
https://localhost |
Origin users reach the app at (scheme, host, and a non-default port), used for generated links and the Discord OAuth redirect. An https:// origin other than localhost also marks the session cookie Secure. Replaces PHX_SCHEME, PHX_HOST, and PHX_URL_PORT, which still work and log a warning. |
PORT |
4000 |
Port the server binds inside the container. |
TRUST_PROXY_HEADERS |
unset | Set to true behind a reverse proxy so rate limiting identifies clients by x-real-ip / x-forwarded-for instead of the proxy address. The headers are believed only from TRUSTED_PROXIES. |
TRUSTED_PROXIES |
loopback and private ranges | Comma-separated addresses or CIDR ranges (for example 192.168.1.20 or 172.16.0.0/12) whose forwarded headers are believed when TRUST_PROXY_HEADERS is on; requests from other addresses are counted by their own address. Narrow it to your proxy when untrusted clients share its private network. |
LOG_LEVEL |
info |
debug, info, warning, or error. debug explains why the Discord bot ignored a message. |
CATALOG_SYNC_INTERVAL_HOURS |
168 |
Hours between automatic Scryfall catalog refreshes. |
RULES_SYNC_ENABLED |
true |
Set to false to stop downloading the Comprehensive Rules for AI rulings and checking daily for new releases. |
CARDID_BUNDLE_SOURCE |
github (off in development) |
Where the card-recognition bundle is downloaded from, at boot and every six hours: github installs the newest published scanner-bundle-* release of this repository, an https://.../manifest.json URL installs the bundle beside it, and off only serves bundles copied in with cardid.publish --to host:DATA_DIR/cardid. |
WEBRTC_SFU_PORT_RANGE |
50000-50100 |
UDP ports the built-in webcam-table SFU listens on, one per connected browser. Forward the same range from your router to the host (Docker: publish it as /udp). |
WEBRTC_SFU_PUBLIC_IP |
unset | Public address browsers reach the SFU ports at, announced as an ICE candidate. Required for players outside your LAN unless WEBRTC_SFU_RELAY_ONLY is set. |
WEBRTC_SFU_IPV6 |
unset | Set to true to also offer the host's IPv6 addresses as media candidates. Off by default: the port forward above is IPv4, and LAN browsers that pick an IPv6 path have lost their video on it. |
WEBRTC_SFU_RELAY_ONLY |
unset | Set to true to open no public ports: the SFU reaches browsers through Cloudflare TURN (requires CLOUDFLARE_TURN_KEY_ID), so all media crosses the relay. |
WEBRTC_STUN_URLS |
Google + Cloudflare public STUN | Comma-separated STUN URLs browsers use for NAT discovery toward the SFU. none disables STUN for LAN-only installs. |
WEBRTC_TURN_URLS |
unset | Comma-separated TURN URLs; with WEBRTC_TURN_USERNAME and WEBRTC_TURN_CREDENTIAL, relays webcam-table media for browsers that cannot reach the SFU directly. |
CLOUDFLARE_TURN_KEY_ID |
unset | With CLOUDFLARE_TURN_API_TOKEN, a Cloudflare Realtime TURN key. The server mints per-join credentials that expire after CLOUDFLARE_TURN_TTL_SECONDS (default 21600); relayed traffic is free up to 1,000 GB/month, then $0.05/GB. |
MANAVAULT_URL |
unset | Origin of a self-hosted ManaVault instance whose shared deck links are recognized and resolved. |
MANAVAULT_ALLOWED_HOSTS |
unset | Comma-separated exact hostnames allowed for personal ManaVault listing on private networks; also permits HTTP for those hosts. |
MANAVAULT_ALLOW_INSECURE_URLS |
unset | Set to true to permit HTTP personal ManaVault origins that resolve to public addresses. |
DISCORD_CLIENT_ID |
unset | Discord application client ID; enables member OAuth sign-in when paired with the secret. |
DISCORD_CLIENT_SECRET |
unset | Discord application client secret. |
DISCORD_BOT_TOKEN |
unset | Enables the optional Discord bot: SpellBot tracking, /log, /summary, and /newgame webcam-table queues. |
DISCORD_GUILD_ID |
unset | Optional server ID for immediate guild-scoped /log, /summary, /newgame, and /close registration; without it commands are global. Restricts invocation to that server when set. |
DISCORD_DEFAULT_TIMEZONE |
America/New_York |
IANA timezone for /newgame start:8pm and tomorrow 7pm. Discord displays parsed timestamps in each viewer's local time. |
DISCORD_NEWGAME_ROLE_ID |
unset | Optional role ID mentioned in each /newgame thread, which adds the role's members to the thread so it shows in their sidebar. Members toggle it with /alerts. |
DISCORD_SPELLBOT_USER_ID |
725510263251402832 |
Discord user ID accepted as SpellBot, useful when running a private SpellBot deployment. |
THE_GATHERING_ADMIN_USERNAME |
unset | Creates this admin on container startup when paired with the password. |
THE_GATHERING_ADMIN_PASSWORD |
unset | Password for container or Mix-task admin bootstrap. |
The first account registered in the browser becomes the server administrator and is the only
password account. After bootstrap, members sign in with Discord. New Discord identities are
accepted only while Open registration is enabled under Admin → Users; existing linked
members can always sign in. This flag is stored in the singleton server_settings row.
For headless container bootstrap, set THE_GATHERING_ADMIN_USERNAME and
THE_GATHERING_ADMIN_PASSWORD. Startup creates the admin if absent and is idempotent on later
restarts. In a source checkout, the equivalent command is:
cd rust
THE_GATHERING_ENV=dev THE_GATHERING_ADMIN_PASSWORD='use-a-long-password' \
mise exec -- cargo run -- create-admin USERNAMEDetailed statistics from on the same page sets a cutoff date for statistics that depend on data your pod may not have recorded from the start: games before it still count toward wins and losses, but their seat positions, game length, turn counts, and MVP cards are left out. See Statistics API.
Administrators can disable and re-enable accounts, or permanently delete an account. Disabling an account signs it out on every device; re-enabling it does not revive those sessions, so the user must sign in again. Administrators can also sign an account out everywhere without changing whether it is enabled. Deleting an account requires its linked player to have zero games and permanently removes the account, sessions, player, and unused decks together. Games recorded by the account for other players are preserved. Disable accounts with game history instead.
Admin → Player identities lists players (including archived players), their Discord IDs, and linked accounts. Search by player name, Discord ID, or username, then unlink an incorrect identity before merging players. Unlinking detaches both the Discord identity and account from the player, preserving the player's games and decks. It does not delete the account or change its Discord login; a future sign-in or import may create a separate player. Use Admin → Users to link the account to the correct player. Identity management requires recent administrator authentication.
Sessions use random tokens stored in the users_tokens table, carried in an encrypted
the_gathering_session cookie. Upgrading from 0.2 or earlier keeps everyone signed in: the
first request with the old _the_gathering_key cookie moves its session into the new cookie.
Browser tabs left open across that upgrade need one reload before saving changes (their page
holds an old-format CSRF token). The session cookie and its token last 14 days and are reissued after 7, so members who
visit at least every two weeks stay signed in; Discord sign-in skips the consent screen once a
member has authorized the app. Signing out ends only the current device's session. Changing the
administrator password expires every session for that account, and expired session rows are pruned
when a new session is issued. Sensitive actions—including import commits and player merges—require
authentication within the previous ten minutes; previews and sample downloads remain available
without reauthentication.
The SPA prompts the administrator for a password and Discord members to authorize with Discord
again. Passwords must be 12–72 characters.
Password login and bootstrap registration (POST /api/session, POST /api/users) are limited
to 10 attempts per client address every 5 minutes, login additionally to 10 attempts per
username from any address, and both together to 100 attempts server-wide; further attempts get
429 Too Many Requests with a retry-after header. Behind a reverse proxy every request
arrives from the proxy's address, so set TRUST_PROXY_HEADERS=true when your proxy sets
x-real-ip or x-forwarded-for. The server believes those headers only when the request comes
from TRUSTED_PROXIES (loopback and private addresses unless set), so clients reaching port
4000 directly cannot pick their own address. Password reauthentication is separately limited by
account and client address, with a server-wide attempt budget. Discord sign-in is not rate
limited here.
Members can create personal API keys under Settings → API keys to list games from scripts
(GET /api/v1/games, filterable by player and date). A key acts as its owner with the same
permissions and stops working when the account is disabled. See Personal API keys.
See Discord integration for bot creation, permissions, and current tracking behavior. See CSV game import for the spreadsheet format and admin import flow, and Mythic Track import for moving an existing Mythic Track playgroup over.
Administration → Audit log records member and administrator operations, with the actor's identity at the time, target, request ID, response status, and paginated before/after snapshots. Both audit history and Server logs require an administrator with authentication in the last ten minutes. Search audit history by username, operation, or target, and filter by outcome.
History includes only operations with committed changes to the audited records below. HTTP API mutations, Discord sign-in callbacks, and handled Discord commands/components carry attribution, but completed operations without row snapshots are discarded. Routine sign-ins, webcam table controls, deck-chooser calculations, no-op saves, and rejected requests without changes are not listed; existing zero-change history is also hidden without deleting it. A sign-in that creates or updates an account still appears. Discord operations are marked Accepted; inspect their committed changes. HTTP failures with partial commits remain visible; an unknown outcome means the request is still running or its final status was not saved.
SQLite triggers record safe columns of users, players, decks, games, game seats, server settings,
API-key metadata, sheet import receipts, pending Discord reports, and result drafts. Snapshots
commit and roll back with the data, including cascaded deletions. Actor attribution is scoped to
each write transaction; deleting or renaming an account does not erase its audit identity. Writes
outside a user operation (such as background jobs or SQL maintenance) retain snapshots with a
NULL operation_id in audit_changes, but are not listed as user operations in the panel.
History starts at this migration, is retained indefinitely in the application database, and has
no UI delete/clear action. For manual reconstruction, follow audit_changes.id order (requests
can overlap), using the related IDs in each snapshot. There is no automatic undo, complete initial
snapshot, or tamper-proof guarantee: database administrators can change this database. Keep database
backups. Audit history is not included in portable game exports. Transient webcam state, scheduled
Discord queues, card images/corrections, catalog/cache data, and external effects are not snapshotted.
Passwords, credential hashes, session tokens, invitation secrets, raw request bodies, and query
strings are excluded. Secret-only rotations that change no safe snapshot field are not retained
as audit operations; API-key creation/deletion and changes to whether a credential is set are.
Server logs follows the Rust process's tracing events over Server-Sent Events, using the same
LOG_LEVEL/RUST_LOG filter as stdout. It retains only the newest 200 messages in the browser,
does not replay old messages, and reports dropped messages if a reader falls behind. Clear only
clears that browser's buffer. Access is rechecked every five seconds and before each batch; logout,
revocation, demotion, or expired authentication ends the stream. Reverse proxies must permit
unbuffered, long-lived SSE responses. System service/container logs remain available separately.
ANSI/control characters and secret-named structured fields are removed, but free-form log messages
are not automatically redacted; application code must never log credentials.
The app downloads Scryfall's compressed default_cards and rulings JSONL feeds when the catalog is empty or no printing has been imported yet (as after upgrading to a release that stores printings), and refreshes them weekly. The responses are streamed to temporary files and decoded incrementally, then a complete staged generation of cards and rulings is published atomically. Every printing in default_cards is stored too (printings a complete sync no longer lists are removed unless a deck uses them). Card search, printing pickers, printing details (including prices, as of the latest sync), and rulings use only SQLite after a sync; no page makes Scryfall API requests. Run a refresh manually from Admin → Catalog → Sync now, which shows the latest sync's state, or with:
docker exec the-gathering /app/bin/the-gathering catalog-sync
# from a checkout: (cd rust && THE_GATHERING_ENV=dev mise exec -- cargo run -- catalog-sync)After each sync (and after every CSV or Mythic Track import) the app links decks and MVP cards that only carry a card name to catalog cards by name, filling in Scryfall IDs and missing colour identities. Trigger that alone from Admin → Catalog → Link imported cards to the catalog or with the-gathering catalog-backfill.
Game Changer badges use Scryfall's game_changer flag for the Commander Brackets list, not a separate hard-coded card list or a bracket recommendation. After deploying the Game Changer migration, run the-gathering catalog-sync once: existing catalog rows initially default to false, and linking/backfilling alone cannot populate this flag. Future catalog syncs keep the badges current, for printings as well as cards (browsers may cache printing details for one hour). Offline recognition-gallery candidates do not carry this metadata; their preview shows the badge once printing details load. No badge means no known positive flag, not a guarantee that the card is absent from the list. Deck summaries label each commander independently; the app does not store full decklists, so it cannot report a whole-deck Game Changer count.
There is one row per Scryfall oracle_id. The preferred printing is English, available on paper, non-digital, and non-promo, then the newest released_at; set code, collector number, and Scryfall UUID break ties. default_cards is used instead of oracle_cards because it provides printing images and lets the app choose that representative deterministically.
In Deck details, use Choose printing below the commander or partner to match the artwork on your card, then Save deck. Use catalog default removes the override. Printing choices come from the printings the catalog sync stores (English paper printings, newest first, with pagination), so browsing needs no Scryfall API request. Saved artwork survives catalog refreshes, even when Scryfall stops listing that printing. Image files use the shared cache described below. Changing a commander or partner clears that slot's printing; printing selection does not change commander identity, colors, imports, or statistics.
Card images (small, normal, art_crop JPEGs from cards.scryfall.io) are served through
authenticated GET /api/card-images?url=…. Stored catalog URLs remain unchanged; API responses
point browsers at this endpoint. It stores the original bytes under DATA_DIR/card-images
(/data/card-images in Docker), shares concurrent requests for the same URL, and fetches at
most four images concurrently. There are no new environment variables or services.
The disk cache is capped at 512 MiB, evicting oldest-written files first on startup and
insertion; files expire after 30 days. Budget up to an additional 2 MiB for an atomic write.
It is disposable and can be omitted from backups or cleared while the app is stopped. Browsers
cache for one day (private, max-age=86400, content ETag). URL timestamps remain part of the
cache key so replacement scans do not collide. Cache misses require internet access; hits do
not. Disk-write failures still serve the downloaded image without caching it.
Only the exact Scryfall image origin and recognized image paths are accepted; redirects are
not followed, downloads over 2 MiB or non-JPEG responses are rejected, and only signed-in users
can fetch. Upstream errors are not cached or automatically retried; HTTP 429 pauses new misses
for at least 30 seconds (or the numeric Retry-After, if longer). At most 128 distinct misses
can wait or run; excess requests return 502. Scryfall's current rate limits
exempt image file origins; the separate card-data API limits remain in force. Images are not
resized, transformed, or stripped of artist/copyright information.
A card can be a commander when, per Comprehensive Rules 903.3, its front face is a legendary creature, Vehicle, or Spacecraft, or its oracle text says it can be your commander. Backgrounds are deliberately excluded. Partner, Partner with, Friends forever, Choose a Background, and Background are stored as a separate pairing classification for deck-building interfaces.
docker build -t the-gathering .Images are published to ghcr.io/cfbender/the-gathering by the container workflow on pushes to main and version tags. Published images are linux/amd64 only; build locally (above) for other architectures.
The backend is a Rust server in rust/ (axum + sqlx on SQLite, the shared
lotus crate for Scryfall and deck-list sources). The
toolchain (Rust, SQLite, Node, aube) is pinned in mise.toml. Install
mise, then:
mise run setup # tools, JavaScript dependencies, server build, demo data
mise run dev # Rust server on $PORT (4000) + Vite on http://localhost:5173Open the Vite port: it serves the React app with hot reload and proxies API, socket, and page
requests to the server. The server applies any missing migrations to the_gathering_dev.db at
startup and syncs the Scryfall catalog when it is empty.
In development (THE_GATHERING_ENV=dev, set by mise run dev) every request is signed in
automatically as the first administrator (a passwordless dev admin is created if none exists)
and sudo re-authentication is skipped. Run with DEV_AUTO_LOGIN=false to exercise the real
login flow; /login stays reachable either way.
Other commands:
mise run rust:check # schema check, cargo fmt --check, clippy -D warnings, cargo test
mise run test # Rust tests only
mise run precommit # Rust checks + frontend checks, tests, and build
mise exec -- aube exec vp check # frontend fmt + lint + typecheck
mise exec -- aube run build # production frontend bundle
mise run rust:new-migration add_thing # new rust/migrations/<timestamp>_add_thing.sql
mise run rust:sqlx-prepare # after a migration or a query changeLayout: rust/ (server, with the SQL migrations in rust/migrations), assets/react
(frontend), priv/static (static files). The version lives in rust/crates/the-gathering/Cargo.toml
and package.json; releases are cut by tagging vX.Y.Z. See AGENTS.md for
conventions.