Concepts
Architecture
Four programs and a database. The backend owns every decision; everything else asks it. This is the map: what talks to what, in what order, and which piece is trusted with what.
The whole system#
graph TB
subgraph players["Players"]
launcher["<b>Launcher</b><br/>Tauri · Rust + React<br/>queue, friends, history"]
end
subgraph admins["Admins"]
dashboard["<b>Dashboard</b><br/>Next.js<br/>server pool, news"]
end
subgraph public["Anyone"]
website["<b>Website</b><br/>Next.js<br/>marketing"]
end
backend["<b>Backend</b><br/>ASP.NET Core · C#<br/>every rule lives here"]
db[("<b>PostgreSQL</b><br/>players, matches,<br/>friendships, servers, queue")]
subgraph games["Community hardware"]
cs2["<b>CS2 servers</b><br/>Metamod + CounterStrikeSharp<br/>+ MatchZy"]
end
steam["<b>Steam</b><br/>OpenID + Web API"]
launcher -->|"HTTPS · bearer token"| backend
dashboard -->|"HTTPS · session cookie"| backend
website -.->|"static, no API calls yet"| backend
backend --> db
backend -->|"RCON · TCP 27015"| cs2
cs2 -->|"match events · HTTPS"| backend
backend -->|"verify sign-in, read profiles"| steam
launcher -->|"sign in, in the system browser"| steamThe rule that shapes everything: the backend decides, the clients display. No client is trusted with identity, match results, or who may do what.
Inside the backend#
Controllers are thin: authenticate, validate, call a service, shape a response. Anything with a rule in it (who may claim a server, what a fair match is, when a lease has expired) lives in a service, so the matchmaker and an admin pressing a button go through exactly the same code.
graph LR
subgraph controllers["Controllers · HTTP"]
auth["AuthController<br/>sign-in, sessions, /me"]
queue["QueueController<br/>join, accept, vote"]
party["PartyController<br/>invite, accept, kick"]
social["SocialController<br/>/api/social/state, polled"]
adminSrv["AdminServersController<br/>the pool, admin only"]
srvApi["MatchServerController<br/>ServersController<br/>called by CS2 servers"]
end
subgraph services["Services · the rules"]
currentPlayer["CurrentPlayer<br/>token or cookie → Player"]
serverAuth["ServerAuth<br/>X-Server-Token → GameServer"]
matchmaker["Matchmaker<br/>queue → matches, phases"]
partySvc["Parties<br/>who may join, leave, invite"]
pool["ServerPool<br/>claim · release · sweep"]
starter["MatchStarter<br/>token + RCON command"]
results["MatchResults<br/>finish once, apply ratings"]
rcon["Rcon<br/>Source RCON client"]
end
subgraph loops["Background loops"]
poolJanitor["PoolJanitor · 30s"]
mmJanitor["MatchmakerJanitor · 2s"]
end
db[("PostgreSQL · EF Core")]
auth --> currentPlayer
queue --> currentPlayer
queue --> partySvc
party --> partySvc
social --> currentPlayer
adminSrv --> starter
srvApi --> serverAuth
srvApi --> pool
srvApi --> results
mmJanitor --> matchmaker
mmJanitor --> partySvc
mmJanitor --> pool
mmJanitor --> starter
poolJanitor --> pool
pool --> rcon
starter --> rcon
services --> db
loops --> db| Service | Responsibility |
|---|---|
CurrentPlayer | Turns a bearer token or the flicked.session cookie into a player |
ServerAuth | Turns an X-Server-Token header into a registered game server |
SteamOpenId · SteamProfile | Verifies sign-in with Steam server to server, and reads names and avatars |
Matchmaker | Forms matches from the queue and advances accept and vote phases |
Parties · Social | Party rules, and the combined friends and party state a screen draws |
ServerPool | Claims, releases and sweeps servers on leases |
MatchStarter | Mints the match token and sends the RCON command that loads the match |
MatchResults · Rating | Finishes a match exactly once and applies the rating arithmetic |
Secrets · ServerSecrets | Hash what you only check, encrypt what you have to replay |
NewsPosts | Publishes, edits and removes news posts |
Signing in#
Two clients, two ways to carry a session, one Sessions table.
sequenceDiagram
participant L as Launcher (Rust)
participant B as Browser
participant API as Backend
participant S as Steam
Note over L: the launcher can hold a secret,<br/>a browser cannot
L->>L: listen on 127.0.0.1:free port
L->>B: open /auth/steam/login?port=…
B->>API: GET /auth/steam/login
API->>B: redirect to Steam
B->>S: sign in (password never touches FLICKED)
S->>B: redirect back with an assertion
B->>API: GET /auth/steam/callback
API->>S: check_authentication (server to server)
S-->>API: is_valid:true
API->>API: find or create Player by Steam ID
API->>B: redirect to 127.0.0.1:port?code=…
B->>L: the one-time code
L->>API: POST /auth/exchange { code }
API-->>L: session token (shown once)
L->>L: store in the OS keychain- Why a code rather than the token: URLs end up in browser history and logs. The code is single-use, expires in two minutes, and the token only ever travels in a request the launcher makes itself.
- The dashboard uses the same verification but ends differently: the backend sets an
HttpOnly cookie and redirects back, because a browser cannot keep a token safely.
CurrentPlayeraccepts either. - Verification is server to server. Everything arriving from the browser is a claim; only Steam confirming it makes it true.
A match, start to finish#
sequenceDiagram
participant P as 10 launchers
participant API as Backend
participant MM as MatchmakerJanitor (2s)
participant Pool as ServerPool
participant CS as CS2 server + MatchZy
P->>API: POST /api/queue
Note over MM: every 2 seconds
MM->>MM: FormMatchesAsync<br/>group by rating window
MM-->>P: match found (polled)
P->>API: POST /api/queue/accept ×10
MM->>MM: all accepted → Voting
P->>API: POST /api/queue/vote ×10
MM->>MM: most votes wins → Pending
MM->>Pool: ClaimAsync(mode, matchId)
Note over Pool: SELECT … FOR UPDATE SKIP LOCKED<br/>so two matches cannot take one server
Pool-->>MM: a server, now Reserved
MM->>CS: RCON: matchzy_loadmatch_url url X-Match-Token token
CS->>API: GET /api/matches/{id}/config
API-->>CS: roster, map, and where to report
CS->>API: series_start
CS->>API: going_live
API->>Pool: MarkHosting (lease 5m → 90m)
Note over CS: the match is played
CS->>API: round_end, map_result (score + stats)
CS->>API: series_end
API->>Pool: ReleaseAsync → Idle- The config carries its own reporting settings, so a stock MatchZy needs nothing configured by hand beyond an RCON password.
- MatchZy never retries and never deduplicates. So every handler is idempotent, and the
lease is the safety net: a lost
series_endcannot strand a server.
Two state machines#
A match#
stateDiagram-v2
direction LR
[*] --> Accepting: a full match found,<br/>or a short one after 20s
Accepting --> Voting: everyone accepted
Accepting --> Cancelled: someone declined<br/>or the 20s ran out
Voting --> Pending: map chosen (15s)
Pending --> Live: going_live
Live --> Finished: map_result / series_end
Pending --> Cancelled: no server, or nobody connected
Finished --> [*]
Cancelled --> [*]A server in the pool#
stateDiagram-v2
direction LR
[*] --> Offline: registered by an admin
Offline --> Idle: answers RCON (checked every 30s)
Idle --> Offline: stops answering
Idle --> Reserved: claimed for a match
Reserved --> Hosting: going_live
Hosting --> Idle: series_end
Reserved --> Idle: lease expired (20m)
Hosting --> Idle: lease expired (90m)The lease arrows are the ones that matter. Without them, one crashed server leaves the pool silently and forever.
The data#
erDiagram
Players ||--o{ Sessions : "signs in"
Players ||--o{ LoginCodes : "one-time"
Players ||--o| PartyMembers : "is in at most one"
Parties ||--|{ PartyMembers : "has"
Parties ||--o{ PartyInvites : "has pending"
Parties ||--o| QueueEntry : "waits, as one"
Players ||--o{ MatchPlayers : "plays"
Players ||--o{ Friendships : "requester / addressee"
Matches ||--|{ MatchPlayers : "has ten"
Matches ||--o| Servers : "is hosted on"
Players {
int Id PK
string SteamId UK
string Name
int Rating
bool IsAdmin
}
Matches {
int Id PK
string Map
int ScoreA
int ScoreB
enum Status
string ConfigTokenHash
}
MatchPlayers {
int MatchId PK, FK
int PlayerId PK, FK
int Team
int Kills
int Deaths
int RatingDelta
}
Servers {
int Id PK
string Host
int Port
string RconPasswordEncrypted
string TokenHash
enum Status
datetime LeaseUntil
}
QueueEntry {
int PartyId UK
enum Mode
datetime JoinedAt
}
Parties {
int Id PK
int LeaderId FK
}- Every queue entry is a party, and somebody playing alone is a party of one. There is no second shape of queue entry for solo players, so there is no solo path for the party path to disagree with. See Matchmaking & parties.
- Derived, never stored: a player's rank, win rate, K/D and damage per round; a match's result; whether a server is free. Anything stored twice eventually disagrees with itself.
Who is trusted with what#
| Credential | Held by | Stored as | Proves |
|---|---|---|---|
| Session token | Launcher, in the OS keychain | SHA-256 hash | You are this player |
| Session cookie | Browser, HttpOnly | The same Sessions row | You are this admin |
| Server token | A CS2 server's config | SHA-256 hash | This is that server |
| Match token | Sent in the RCON command | SHA-256 hash | This is that one match |
| RCON password | The backend | Encrypted (Data Protection) | Lets FLICKED command a server |
Hash what you only check; encrypt what you have to replay. An RCON password has to be sent again, so it cannot be hashed. Everything else is only ever compared, so it is never stored in a form anyone could use.
The launcher's webview never sees a token. Rust holds it, makes the authenticated calls, and hands the interface names and numbers only, so a scripting bug in the UI cannot walk off with a session.
Inside the launcher#
graph LR
subgraph webview["Webview · React + TypeScript"]
views["Views<br/>Play · Matches · Friends<br/>Leaderboard · News"]
hooks["Hooks<br/>useSession · useSocial<br/>useQueue · useParty · usePresence"]
api["lib/api.ts<br/>public endpoints"]
end
subgraph rust["Rust · src-tauri"]
authrs["auth.rs<br/>Steam sign-in, keychain"]
apirs["api.rs<br/>authenticated calls"]
presence["presence.rs<br/>Discord rich presence"]
end
backend["Backend"]
views --> hooks
hooks --> api
hooks -->|"invoke()"| authrs
hooks -->|"invoke()"| apirs
api -->|"news, leaderboard"| backend
apirs -->|"+ bearer token"| backend
authrs --> backendThe split: public data (news, leaderboard) is fetched straight from the webview; anything about you goes through Rust, because that is where the token lives. Each authenticated endpoint gets its own command rather than one general "call this path", so a compromised webview cannot borrow the token freely.
Inside the dashboard#
graph LR
routes["app/servers · app/news<br/>server components"]
shell["dash-shell.tsx<br/>the admin gate"]
session["lib/session.ts<br/>forwards the cookie"]
panels["servers-panel · news-panel<br/>client components"]
libs["lib/servers.ts · lib/news.ts<br/>credentials: include"]
backend["Backend<br/>/api/admin/servers<br/>/api/admin/news"]
routes --> shell --> session --> backend
routes --> panels --> libs --> backendThe gate is a component, not a layout. DashShell wraps each page rather than
app/layout.tsx doing the checking, because the gate has to know which page was asked for: that
is the returnTo Steam sends somebody back to.
The admin check runs on the server on every request, with no caching, so a revoked session or a removed admin flag takes effect immediately.
What runs on a timer#
| Loop | Every | Does |
|---|---|---|
MatchmakerJanitor | 2s | Form matches, advance accept and vote phases, start ready matches, sweep expired party invites |
PoolJanitor | 30s | Reclaim expired leases, check each server over RCON |
Both open a fresh DI scope per pass (a BackgroundService is a singleton and must never hold a
DbContext) and swallow exceptions, because one bad pass must not end the loop for the life of
the process.
Where the seams are#
Things deliberately left as boundaries, so they can be replaced without touching the rest:
- The pool answers one question: "give me a server for this match". Today the answer comes from a table of registered servers. Automatic provisioning would be another way a row appears, and matchmaking would never notice.
- MatchZy sits behind two endpoints, a config and an event feed. A custom CounterStrikeSharp plugin would speak the same two.
- Presence and the queue are polled today. SignalR would replace the polling without changing what the screens render.