Skip to content

Commit f791396

Browse files
committed
Add game state machine
Again, all AI generated
1 parent 03c207e commit f791396

File tree

2 files changed

+277
-1
lines changed

2 files changed

+277
-1
lines changed
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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 |

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ We support schemas for HTTP ([OpenAPI](https://www.openapis.org)) and messaging
88

99
An up-to-date rendered ui is available at https://faforever.github.io/faf-api-specs.
1010

11-
A visual overview of the client/player lifecycle and how lobby messages drive state transitions is available in [state-machine.md](lobby-to-client/state-machine.md).
11+
A visual overview of the client/player lifecycle and how lobby messages drive state transitions is available in [state-machine.md](lobby-to-client/state-machine.md). The companion [game-state-machine.md](lobby-to-client/game-state-machine.md) documents the FA game instance lifecycle and GPGNet protocol.
1212

1313
Larger parts of the specs are generated by AI, so there may be mistakes. Feel free to open an issue or PR if you find any.

0 commit comments

Comments
 (0)