Skip to content
FLICKED Docs

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"| steam

The 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
ServiceResponsibility
CurrentPlayerTurns a bearer token or the flicked.session cookie into a player
ServerAuthTurns an X-Server-Token header into a registered game server
SteamOpenId · SteamProfileVerifies sign-in with Steam server to server, and reads names and avatars
MatchmakerForms matches from the queue and advances accept and vote phases
Parties · SocialParty rules, and the combined friends and party state a screen draws
ServerPoolClaims, releases and sweeps servers on leases
MatchStarterMints the match token and sends the RCON command that loads the match
MatchResults · RatingFinishes a match exactly once and applies the rating arithmetic
Secrets · ServerSecretsHash what you only check, encrypt what you have to replay
NewsPostsPublishes, 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. CurrentPlayer accepts 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_end cannot 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#

CredentialHeld byStored asProves
Session tokenLauncher, in the OS keychainSHA-256 hashYou are this player
Session cookieBrowser, HttpOnlyThe same Sessions rowYou are this admin
Server tokenA CS2 server's configSHA-256 hashThis is that server
Match tokenSent in the RCON commandSHA-256 hashThis is that one match
RCON passwordThe backendEncrypted (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 --> backend

The 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 --> backend

The 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#

LoopEveryDoes
MatchmakerJanitor2sForm matches, advance accept and vote phases, start ready matches, sweep expired party invites
PoolJanitor30sReclaim 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.
Esc
↑↓ navigate ↵ open