|
| 1 | +# FA Game Instance State Machine |
| 2 | + |
| 3 | +This diagram documents the **game instance lifecycle** from the **server's perspective**, showing how GPGNet commands drive state transitions inside a `Game` object. States correspond to the `GameState` and `GameConnectionState` enums tracked server-side. |
| 4 | + |
| 5 | +For the player/client lifecycle (PlayerState transitions driven by lobby messages), see [state-machine.md](state-machine.md). |
| 6 | +For the full message schema, see [v1/asyncapi.yml](v1/asyncapi.yml). |
| 7 | + |
| 8 | +## Game State Machine |
| 9 | + |
| 10 | +```mermaid |
| 11 | +stateDiagram-v2 |
| 12 | + [*] --> INITIALIZING : GameService.create_game() |
| 13 | +
|
| 14 | + state "INITIALIZING (GameState.INITIALIZING)" as INITIALIZING |
| 15 | + INITIALIZING : Game created, host assigned |
| 16 | + INITIALIZING : setup_timeout ticking (30s custom / 60s ladder) |
| 17 | +
|
| 18 | + INITIALIZING --> LOBBY : Host → GameState("Idle") |
| 19 | + INITIALIZING --> ENDED : setup_timeout expires |
| 20 | +
|
| 21 | + state "LOBBY (GameState.LOBBY)" as LOBBY { |
| 22 | + state "Host Listening" as HostListening |
| 23 | + HostListening : ← HostGame(map_folder_name) sent to host |
| 24 | + HostListening : game.set_hosted() called |
| 25 | +
|
| 26 | + state "Configuring" as Configuring |
| 27 | + Configuring : → GameOption(key, value) |
| 28 | + Configuring : → PlayerOption(id, key, value) |
| 29 | + Configuring : → AIOption(name, key, value) |
| 30 | + Configuring : → ClearSlot(slot) |
| 31 | + Configuring : → GameMods(mode, uids) |
| 32 | +
|
| 33 | + state "Joiners Connecting" as JoinersConnecting |
| 34 | + JoinersConnecting : ← JoinGame(host_name, host_id) to joiner |
| 35 | + JoinersConnecting : ← ConnectToPeer(name, id, offer) to peers |
| 36 | + JoinersConnecting : → IceMsg relayed between peers |
| 37 | +
|
| 38 | + HostListening --> Configuring |
| 39 | + Configuring --> Configuring : host changes options |
| 40 | + Configuring --> JoinersConnecting : joiner → GameState("Lobby") |
| 41 | + JoinersConnecting --> Configuring : joiner connected |
| 42 | + } |
| 43 | +
|
| 44 | + LOBBY --> LIVE : Host → GameState("Launching")\ngame.launch() |
| 45 | + LOBBY --> ENDED : Host disconnects /\nall connections lost |
| 46 | +
|
| 47 | + state "LIVE (GameState.LIVE)" as LIVE { |
| 48 | + state "Simulation Running" as SimRunning |
| 49 | + SimRunning : _players_at_launch frozen |
| 50 | + SimRunning : game_stats + game_player_stats written to DB |
| 51 | + SimRunning : validity checks applied |
| 52 | +
|
| 53 | + SimRunning --> SimRunning : → GameResult(army, result)\n→ JsonStats(stats)\n→ Desync\n→ TeamkillHappened(...)\n→ OperationComplete(...) |
| 54 | +
|
| 55 | + state "Simulation Finished" as SimFinished |
| 56 | + SimFinished : All connections finished_sim = True |
| 57 | + SimFinished : check_sim_end() then endTime written to DB |
| 58 | +
|
| 59 | + SimRunning --> SimFinished : All players → GameEnded /\nall connections closed |
| 60 | + } |
| 61 | +
|
| 62 | + LIVE --> ENDED : on_game_finish()\nprocess results → rating update |
| 63 | +
|
| 64 | + state "ENDED (GameState.ENDED)" as ENDED |
| 65 | + ENDED : Results persisted, game marked dirty |
| 66 | + ENDED --> [*] : GameService.remove_game() |
| 67 | +``` |
| 68 | + |
| 69 | +## GameConnectionState Machine |
| 70 | + |
| 71 | +Each player's connection to a game is tracked independently. The host and joiners follow different paths through these states. |
| 72 | + |
| 73 | +```mermaid |
| 74 | +stateDiagram-v2 |
| 75 | + [*] --> GC_INITIALIZING : GameConnection created\n(in _prepare_launch_game) |
| 76 | +
|
| 77 | + state "INITIALIZING\n(GameConnectionState.INITIALIZING)" as GC_INITIALIZING |
| 78 | + GC_INITIALIZING : setup_timeout ticking (default 60s) |
| 79 | +
|
| 80 | + state "INITIALIZED\n(GameConnectionState.INITIALIZED)" as GC_INITIALIZED |
| 81 | + GC_INITIALIZED : Joiner sent GameState("Idle") |
| 82 | + GC_INITIALIZED : player.state = JOINING |
| 83 | +
|
| 84 | + state "CONNECTED_TO_HOST\n(GameConnectionState.CONNECTED_TO_HOST)" as GC_CONNECTED |
| 85 | +
|
| 86 | + state "ENDED\n(GameConnectionState.ENDED)" as GC_ENDED |
| 87 | +
|
| 88 | + GC_INITIALIZING --> GC_CONNECTED : Host → GameState("Idle")\n[game.state = LOBBY, player.state = HOSTING] |
| 89 | + GC_INITIALIZING --> GC_INITIALIZED : Joiner → GameState("Idle")\n[player.state = JOINING] |
| 90 | + GC_INITIALIZING --> GC_ENDED : setup_timeout expires, abort() |
| 91 | +
|
| 92 | + GC_INITIALIZED --> GC_CONNECTED : Joiner → GameState("Lobby")\n[connect_to_host, peer mesh setup] |
| 93 | + GC_INITIALIZED --> GC_ENDED : abort() (host left, error) |
| 94 | +
|
| 95 | + GC_CONNECTED --> GC_ENDED : → GameState("Ended") /\nconnection closed / abort() |
| 96 | +
|
| 97 | + GC_ENDED --> [*] : player.state = IDLE |
| 98 | +``` |
| 99 | + |
| 100 | +## Lobby Phase — GPGNet Handshake |
| 101 | + |
| 102 | +This sequence shows the full GPGNet command flow during lobby setup for a host with two joiners. The `offer` parameter on `ConnectToPeer` determines ICE initiator (`true`) vs responder (`false`). |
| 103 | + |
| 104 | +```mermaid |
| 105 | +sequenceDiagram |
| 106 | + participant Host as Host (FA) |
| 107 | + participant Server as Server |
| 108 | + participant J1 as Joiner 1 (FA) |
| 109 | + participant J2 as Joiner 2 (FA) |
| 110 | +
|
| 111 | + Note over Host,Server: Host Setup |
| 112 | + Host->>Server: GameState("Idle") |
| 113 | + Note over Server: game.state = LOBBY<br/>conn.state = CONNECTED_TO_HOST<br/>player.state = HOSTING |
| 114 | + Host->>Server: GameState("Lobby") |
| 115 | + Server->>Host: HostGame(map_folder_name) |
| 116 | + Note over Server: game.set_hosted() |
| 117 | +
|
| 118 | + Note over J1,Server: Joiner 1 Connects |
| 119 | + J1->>Server: GameState("Idle") |
| 120 | + Note over Server: conn.state = INITIALIZED<br/>player.state = JOINING |
| 121 | + J1->>Server: GameState("Lobby") |
| 122 | + Server->>J1: JoinGame(host_name, host_id) |
| 123 | + Server->>Host: ConnectToPeer(j1_name, j1_id, offer=true) |
| 124 | + Note over Server: j1 conn.state = CONNECTED_TO_HOST |
| 125 | +
|
| 126 | + Note over J2,Server: Joiner 2 Connects |
| 127 | + J2->>Server: GameState("Idle") |
| 128 | + Note over Server: conn.state = INITIALIZED<br/>player.state = JOINING |
| 129 | + J2->>Server: GameState("Lobby") |
| 130 | + Server->>J2: JoinGame(host_name, host_id) |
| 131 | + Server->>Host: ConnectToPeer(j2_name, j2_id, offer=true) |
| 132 | + Note over Server: Peer mesh: J1 ↔ J2 |
| 133 | + Server->>J2: ConnectToPeer(j1_name, j1_id, offer=true) |
| 134 | + Server->>J1: ConnectToPeer(j2_name, j2_id, offer=false) |
| 135 | + Note over Server: j2 conn.state = CONNECTED_TO_HOST |
| 136 | +
|
| 137 | + Note over Host,J2: Game Launch |
| 138 | + Host->>Server: GameState("Launching") |
| 139 | + Note over Server: game.launch()<br/>state = LIVE<br/>freeze _players_at_launch<br/>all players → PLAYING<br/>write game_stats to DB |
| 140 | +``` |
| 141 | + |
| 142 | +## ICE / Peer-to-Peer Connection Flow |
| 143 | + |
| 144 | +All game traffic flows peer-to-peer over UDP. The server only relays ICE signaling messages to establish the direct connection. |
| 145 | + |
| 146 | +```mermaid |
| 147 | +sequenceDiagram |
| 148 | + participant A as Player A (FA) |
| 149 | + participant S as Server |
| 150 | + participant B as Player B (FA) |
| 151 | +
|
| 152 | + S->>A: ConnectToPeer(B_name, B_id, offer=true) |
| 153 | + S->>B: ConnectToPeer(A_name, A_id, offer=false) |
| 154 | +
|
| 155 | + Note over A,B: ICE candidate exchange via server relay |
| 156 | + A->>S: IceMsg(B_id, ice_candidate) |
| 157 | + S->>B: IceMsg(A_id, ice_candidate) |
| 158 | + B->>S: IceMsg(A_id, ice_candidate) |
| 159 | + S->>A: IceMsg(B_id, ice_candidate) |
| 160 | +
|
| 161 | + Note over A,B: Direct UDP connection established |
| 162 | + A-->>B: Game simulation data (peer-to-peer) |
| 163 | +``` |
| 164 | + |
| 165 | +## Game End Flow |
| 166 | + |
| 167 | +```mermaid |
| 168 | +sequenceDiagram |
| 169 | + participant FA as FA Client(s) |
| 170 | + participant GC as GameConnection |
| 171 | + participant Game as Game |
| 172 | + participant DB as Database |
| 173 | + participant MQ as Message Queue |
| 174 | +
|
| 175 | + FA->>GC: GameResult(army, "victory 1") |
| 176 | + GC->>Game: add_result(reporter, army, outcome, score) |
| 177 | +
|
| 178 | + FA->>GC: JsonStats(stats_json) |
| 179 | + GC->>Game: report_army_stats(stats) |
| 180 | +
|
| 181 | + FA->>GC: GameEnded |
| 182 | + GC->>GC: finished_sim = True |
| 183 | + GC->>Game: check_game_finish(player) |
| 184 | +
|
| 185 | + Game->>Game: check_sim_end() |
| 186 | + Note over Game: All finished_sim?<br/>→ game.finished = True |
| 187 | + Game->>DB: UPDATE game_stats SET endTime |
| 188 | +
|
| 189 | + Game->>Game: on_game_finish() |
| 190 | + alt desyncs > 20 |
| 191 | + Game->>Game: mark_invalid(TOO_MANY_DESYNCS) |
| 192 | + else normal finish |
| 193 | + Game->>Game: process_game_results() |
| 194 | + Game->>DB: persist_results() — update scores |
| 195 | + Game->>Game: resolve_game_results() |
| 196 | + Game->>MQ: publish_game_results → rating service |
| 197 | + end |
| 198 | + Note over Game: state = ENDED |
| 199 | +``` |
| 200 | + |
| 201 | +## GPGNet Command Reference |
| 202 | + |
| 203 | +### FA → Server (game client sends) |
| 204 | + |
| 205 | +| Command | Parameters | Phase | Description | |
| 206 | +|---|---|---|---| |
| 207 | +| `GameState` | `state` ("Idle", "Lobby", "Launching", "Ended") | All | Drives game and connection state transitions | |
| 208 | +| `GameOption` | `key, value` | LOBBY | Host sets game option (Victory, Cheats, Speed, etc.) | |
| 209 | +| `PlayerOption` | `player_id, key, value` | LOBBY | Host sets player option (Faction, Team, Color, StartSpot, Army) | |
| 210 | +| `AIOption` | `ai_name, key, value` | LOBBY | Host configures AI player | |
| 211 | +| `ClearSlot` | `slot` | LOBBY | Host removes player/AI from slot | |
| 212 | +| `GameMods` | `mode, args` | LOBBY | Report activated mods ("activated" count or "uids" list) | |
| 213 | +| `EnforceRating` | _(none)_ | LOBBY | Host requests rating enforcement | |
| 214 | +| `GameFull` | _(none)_ | LOBBY | All slots filled (informational) | |
| 215 | +| `Chat` | `message` | LOBBY | In-lobby chat message | |
| 216 | +| `LaunchStatus` | `status` | LOBBY | "Rejected" if matchmaker launch failed | |
| 217 | +| `GameResult` | `army, result_string` | LIVE | Army outcome (e.g. "victory 1", "defeat 0") | |
| 218 | +| `GameEnded` | _(none)_ | LIVE | Simulation has ended for this player | |
| 219 | +| `JsonStats` | `stats_json` | LIVE | Army statistics JSON blob | |
| 220 | +| `OperationComplete` | `primary, secondary, time_delta` | LIVE | Coop mission completion | |
| 221 | +| `Desync` | _(none)_ | LIVE | Desync event (>20 → game invalid) | |
| 222 | +| `TeamkillHappened` | `gametime, victim_id, victim_name, tk_id, tk_name` | LIVE | Automatic teamkill report | |
| 223 | +| `TeamkillReport` | `gametime, reporter_id, reporter_name, tk_id, tk_name` | LIVE | Player-initiated teamkill report | |
| 224 | +| `IceMsg` | `receiver_id, ice_msg_json` | LOBBY/LIVE | ICE candidate relay for P2P setup | |
| 225 | +| `Bottleneck` | `code, ...args` | LIVE | Player data processing bottleneck | |
| 226 | +| `BottleneckCleared` | _(none)_ | LIVE | Bottleneck resolved | |
| 227 | +| `Disconnected` | `...args` | LIVE | Peer disconnected (logged only) | |
| 228 | +| `Rehost` | `...args` | LOBBY | Game rehosted (unused) | |
| 229 | + |
| 230 | +### Server → FA (server sends to game client) |
| 231 | + |
| 232 | +| Command | Parameters | When Sent | Description | |
| 233 | +|---|---|---|---| |
| 234 | +| `HostGame` | `map_path` | Host sends GameState("Lobby") | Tell host FA to start listening for peer connections | |
| 235 | +| `JoinGame` | `player_name, player_uid` | Joiner sends GameState("Lobby") | Tell joiner FA to connect to host | |
| 236 | +| `ConnectToPeer` | `player_name, player_uid, offer` | Joiner enters lobby | Establish peer mesh; `offer=true` = ICE initiator | |
| 237 | +| `DisconnectFromPeer` | `player_id` | Peer leaves during LOBBY | Tell FA to disconnect from a specific peer | |
| 238 | +| `IceMsg` | `sender_id, ice_msg_json` | During ICE negotiation | Relayed ICE candidate from another peer | |
| 239 | + |
| 240 | +## Game Type Variants |
| 241 | + |
| 242 | +| Property | Custom | Coop | Matchmaker | |
| 243 | +|---|---|---|---| |
| 244 | +| **Class** | `CustomGame` | `CoopGame` | `LadderGame` | |
| 245 | +| **InitMode** | `NORMAL_LOBBY` | `NORMAL_LOBBY` | `AUTO_LOBBY` | |
| 246 | +| **GameType** | `custom` | `coop` | `matchmaker` | |
| 247 | +| **Rating** | `global` | not ranked | from queue config | |
| 248 | +| **Setup timeout** | 30s | 30s | 60s | |
| 249 | +| **Lobby config** | Host configures | Host configures | Server pre-sets (faction, team, slot) | |
| 250 | +| **Launch** | Host clicks launch | Host clicks launch | Server waits for `wait_hosted` + `wait_launched` | |
| 251 | +| **Special** | — | `OperationComplete` for coop leaderboard | `LaunchStatus("Rejected")` on settings mismatch | |
| 252 | + |
| 253 | +## State Reference |
| 254 | + |
| 255 | +| GameState | Value | Description | Entry Trigger | |
| 256 | +|---|---|---|---| |
| 257 | +| `INITIALIZING` | 0 | Game created, awaiting host's first GPGNet message | `GameService.create_game()` | |
| 258 | +| `LOBBY` | 1 | Host listening, players joining and configuring | Host sends `GameState("Idle")` | |
| 259 | +| `LIVE` | 2 | Simulation running, results being collected | Host sends `GameState("Launching")` → `game.launch()` | |
| 260 | +| `ENDED` | 3 | Game finished, results processed | `on_game_finish()` | |
| 261 | + |
| 262 | +| GameConnectionState | Value | Description | Entry Trigger | |
| 263 | +|---|---|---|---| |
| 264 | +| `INITIALIZING` | 0 | GameConnection created, FA process starting | `_prepare_launch_game()` | |
| 265 | +| `INITIALIZED` | 1 | Joiner sent `GameState("Idle")`, not yet connected to host | Joiner: `_handle_idle_state()` | |
| 266 | +| `CONNECTED_TO_HOST` | 2 | Peer setup complete, in the game | Host: `_handle_idle_state()` / Joiner: `_handle_lobby_state()` | |
| 267 | +| `ENDED` | 3 | Connection terminated, player returned to IDLE | `abort()` / `on_connection_closed()` | |
| 268 | + |
| 269 | +## Message Legend |
| 270 | + |
| 271 | +| Arrow | Meaning | |
| 272 | +|---|---| |
| 273 | +| `→ message` | Game client (FA) sends GPGNet command to server | |
| 274 | +| `← message` | Server sends GPGNet command to game client (FA) | |
| 275 | +| `→ lobby_command` | Client sends lobby protocol message to server | |
| 276 | +| `← lobby_message` | Server sends lobby protocol message to client | |
0 commit comments