Architecture
One FastAPI app serves three different audiences on one port. That is the whole shape of the system, and most of what follows is a consequence of it.
| Audience | Gets | Authenticated by |
|---|---|---|
| People | Server-rendered Jinja pages | Steam or Discord login, signed session cookie |
| The site’s own scripts | JSON endpoints | The same session cookie |
| Game servers | /server/v1/* |
The caller’s source IP, allow-listed |
Layout
Section titled “Layout”config.yaml # non-secret settingsgamemodes/ # one file per gamemodewebsite/├── public/ # served at /static│ ├── assets/css/ # one stylesheet per page, plus default.css for the shared parts│ ├── assets/js/ # one script per page│ └── img/ maps/ unturned/└── src/ ├── app.py # assembly: middleware, routers, error pages ├── dependencies.py # current user, auth guards, template renderer ├── kits.json # kit catalog ├── core/ # domain logic: settings, db, models, matchmaking, steam ├── routes/ # one APIRouter per area ├── templates/ # Jinja2 pages └── tests/core/ is where behaviour lives. routes/ is thin on purpose: a route reads a request,
calls into core/, and renders. There is no utils.py, and adding one would be a
regression.
The two authentication systems
Section titled “The two authentication systems”They have nothing in common and that is intentional.
People log in through Steam or Discord and carry a signed session cookie.
Permissions are bits on the user row, named in permissions in config.yaml, so the
order of that list is a storage format and not a preference.
Game servers are recognised by source IP, from the AuthorizedServers table. Every
route under /server/v1 returns 401 to an address that is not in it, and nothing in
the site adds one. You seed the first entries with BOOTSTRAP_SERVERS.
SQLModel over SQLAlchemy, and DB takes any SQLAlchemy URL.
There is no migration tool. On startup, create_all builds tables that do not exist,
and then add_missing_columns in src/core/db.py adds columns the models have gained
since their table was built, backfilling a model default where there is one. Every
ALTER it runs is printed at startup.
What that covers, and what it does not:
| Change | Handled |
|---|---|
| New table | Yes |
| New column | Yes, backfilled from the model default |
| Dropped, renamed or retyped column | No. Do it against the database by hand. |
This is a deliberate trade. It means a new SQLModel table reaches production by being deployed, with no migration step to forget. It also means destructive schema changes are manual, on purpose.
Configuration splits by kind, not by environment
Section titled “Configuration splits by kind, not by environment”config.yaml, in the repository root, holds everything non-secret: matchmaking timings, permission bits, Discord channel ids, the lobby’s connect code, and which gamemodes the play page shows.gamemodes/, beside it, holds one file per gamemode. Each one carries its own queue bit.- The environment holds secrets, plus three overrides (
DOMAIN,PROTOCOL,BUILD) so one image can serve both dev and production. kits.jsonholds the kit catalogue, which is machine-shaped data rather than settings.
Everything funnels through one Settings object in src/core/settings.py. Nothing reads
os.environ directly, and nothing hardcodes a value that already lives in config.
Third parties never hold a page open
Section titled “Third parties never hold a page open”Steam is on the request path of pages that have to render without it, so every call into it is treated as decoration:
- Lookups are cached, including failures, so an outage costs one request per cache window rather than one per item per page.
Server.describedoes not call Steam at all. It answers from cache and fills the cache on a background thread, because the admin list asks about every match on it one after another, and a blocking miss multiplies by however many servers are on screen.- Player names and avatars are persisted on the user row when they are fetched, so an ordinary page never has to ask.
If you add a third-party call, match this. A page that renders slowly because a vendor is slow is a page that goes down when the vendor does.
Front end
Section titled “Front end”Server-rendered Jinja, one stylesheet and one script per page, plus HTMX where a partial update is worth it. There is no build step for the application code and no framework.
The only build-time transformation is
minify.py, which
compresses public/assets in place during the Docker build. The repository keeps
readable source, the image serves compressed files, and no template has to know which
build it is in. The compressors are uninstalled in the same layer and never reach the
running container.
- The match lifecycle, which is where the design shows
- The API contract in full
- Forking for another game
