Strategic mode – Developer documentation
Purpose
This document describes how the Strategic mode is implemented end-to-end in Earth Pulse:
backend room lifecycle and game logic,
WebSocket protocol,
frontend state/UI flow,
persistence and leaderboard,
local run and test strategy.
It is based on the current implementation in apiserver/ and frontend/.
1) High-level architecture
Strategic mode is a real-time multiplayer mode built on:
FastAPI REST for room creation.
FastAPI WebSocket for real-time gameplay events.
In-memory room state (
roomsdict) during active matches.PostgreSQL persistence only for game/leaderboard records.
Nuxt frontend with Pinia + composable WebSocket client.
Core backend modules:
apiserver/apiserver/routers/strategic_game.pyapiserver/apiserver/controllers/strategic_game_controller.pyapiserver/apiserver/services/strategic_game_service.pyapiserver/apiserver/models/strategic_room.pyapiserver/apiserver/models/strategic_player.py
Core frontend modules:
frontend/components/create-game.vuefrontend/composables/useStrategicWebSocket.tsfrontend/class/StrategicGame.tsfrontend/class/StrategicPlayer.tsfrontend/pages/strategic-game-board.vue
2) Backend entrypoints
2.1 Router mounting
The Strategic router is mounted with prefix /strategic in apiserver/main.py.
2.2 REST endpoint
POST /strategic/create_room
Creates a strategic room.
Request body:
{
"max_players": 2,
"deck_id": 1,
"mode": "strategic"
}
Response:
{
"room_id": "123456",
"deck_name": "Deck Name"
}
Behavior notes:
max_playersmust be in{2,3,4}depending on the selectedmode.modedetermines game behavior (e.g.,"strategic","tutorial","solo").room_idis a 6-digit numeric string.
2.3 WebSocket endpoints
GET WS /strategic/ws/{room_id}/{pseudo}GET WS /strategic/ws/{room_id}/{pseudo}/{token}
Both delegate to handle_websocket(...) in the strategic controller.
3) In-memory domain model
3.1 StrategicRoom
The room stores transient game state:
identity/config:
room_id,mode,max_players,deck_id,deck_nameplayers:
playerslifecycle:
stateinwaiting -> playing -> finishdeck snapshot:
deck_snapshot,deck_card_idsround state:
round_index,current_indicator,round_indicators,used_indicator_idstiming/rules:
round_duration_seconds,hand_size,max_roundsmetadata:
deck_indicators_meta,indicator_boundssafety:
lock(asyncio.Lock) for concurrent WS mutations
3.2 StrategicPlayer
Transient per-player data:
identity:
id,pseudosocket:
websocketgame state:
hand_card_ids,picked_card_id,scoreflags:
is_bot,is_eliminated,is_deleted
3.3 Cache vs in-memory runtime state
Strategic mode uses both a process cache and in-memory match state, with different roles.
What is cached (CacheService)
Cached data is read-mostly deck metadata reused across rooms:
deck_bundle(cards + raw indicator values) viaget_or_load_deck_bundle(deck_id)deck indicators metadata via
get_or_load_deck_indicators_with_types(deck_id)indicator bounds via
get_or_load_indicator_bounds(deck_id)
Properties:
process-local cache (not shared across pods/instances)
reused by multiple Strategic rooms in the same backend process
can be refreshed/invalidation-managed by cache service logic
What is in memory (live Strategic game state)
Runtime game state is stored in memory and is room-specific:
global strategic room registry:
rooms: dict[str, StrategicRoom]per-room mutable state: players, picks, scores, round index, deadlines, used indicators
per-room
deck_snapshotbuilt at room creation (copy derived from cached deck data)
Properties:
ephemeral (lives only while process is alive)
removed by game flow/disconnect handling and
cleanup_strategic_rooms(...)not shared across instances
Practical consequence
In multi-instance deployments, a player must reconnect to the same backend instance that owns the room in memory. Cache helps reduce DB/load overhead, but it does not replace shared room state.
4) Room and game lifecycle
4.1 Create room
create_strategic_room(...):
normalizes players count using
tutorial/soloflags,validates deck via
DeckService.validate_deck_usage("strategic", ...),loads cached deck bundle,
loads indicator metadata,
snapshots cards,
allocates unique room id,
stores room in global
roomsdict.
4.2 Player join
handle_websocket(...):
validates pseudo with toxicity checker,
accepts socket,
validates room joinability (
exists,not full,state=waiting),creates player + appends to room,
in solo mode, auto-injects
LuckyBot,sends
game_context, broadcastsplayer_join.
4.3 Auto-start
When len(room.players) == room.max_players and state is waiting, server starts game:
state ->
playinground index initialized to
1indicator bounds computed
initial hands dealt
game DB record created
game_startbroadcastfirst
round_startemitted per player (with own hand)
4.4 Round resolution loop
For each round:
clients send
selected_card(player/card ids),server registers picks,
in solo mode bot auto-picks,
when all active players picked ->
resolve_round(...),server broadcasts
round_result,refills hands, increments round index,
starts next round or finishes game.
4.5 Finish and cleanup
On game end:
state ->
finishgame record updated to
finishleaderboard scores persisted (except tutorial / bots)
game_finishbroadcast
Background cleanup task removes stale rooms:
empty room: immediately
finished room older than 5 min
any room older than 1h
5) Strategic scoring system
Scoring is hybrid: risk points + logarithmic bonus.
5.1 Risk points
From percentile of card value within indicator min/max bounds:
percentile >= 0.8 ->
Low risk->400.5 <= percentile < 0.8 ->
Medium risk->100percentile < 0.5 ->
High risk->200
5.2 Logarithmic bonus
Bonus is in [0..100], based on value position in range:
$$ text{bonus} = frac{log(value - min + epsilon)}{log(max - min + epsilon)} times 100 $$
Then clamped to [0,100] and rounded.
5.3 Winner/loser points
Winner(s) (highest value):
risk_points + bonusOthers:
bonusonlyDraw:
winner = null,is_draw = true
All players accumulate cumulative score across rounds.
5.4 Round/game limits
default strategic max rounds:
4tutorial max rounds:
1
6) WebSocket protocol (current implementation)
6.1 Server -> client events
game_context
{
"event": "game_context",
"data": {
"room_id": "1234",
"mode": "strategic",
"max_players": 2,
"current_player_id": 5678,
"deck_name": "...",
"hand_size": 4,
"round_duration": 20,
"players": [{"id": 5678, "pseudo": "Alice"}]
}
}
player_join
{
"event": "player_join",
"players": [{"id": 5678, "pseudo": "Alice"}, {"id": 9012, "pseudo": "Bob"}],
"join": {"id": 9012, "pseudo": "Bob"}
}
game_start
{
"event": "game_start",
"round": 1,
"players": [{"id": 5678, "pseudo": "Alice"}, {"id": 9012, "pseudo": "Bob"}]
}
round_start (per player)
{
"event": "round_start",
"round": 1,
"indicator": {
"id": 11,
"name": "Temperature",
"icon": "...",
"month": 7,
"year": 2039
},
"deadline_ms": 1760000000000,
"hand": [
{
"card_id": 123,
"city": "...",
"country": "...",
"photo": "https://...",
"indicators": [
{
"id": 11,
"name": "Temperature",
"icon": "...",
"risk": "medium risk",
"month": 7,
"year": 2039
},...
]
}
]
}
round_result
{
"event": "round_result",
"round": 1,
"indicator": {"id": 11, "name": "Temperature", "icon": "...", "month": 7, "year": 2039},
"plays": [
{
"player_id": 5678,
"card_id": 123,
"card": {"card_id": 123, "city": "...", "country": "...", "photo": "...", "indicators": [...]},
"value": 18.2,
"risk_level": "Medium risk",
"risk_points": 100,
"bonus": 63,
"points": 163
}
],
"winner": 5678,
"is_draw": false,
"scores": [{"player_id": 5678, "score": 163}, {"player_id": 9012, "score": 55}]
}
game_finish
{
"event": "game_finish",
"leaderboard": [
{"id": 5678, "pseudo": "Alice", "score": 600, "state": "player"},
{"id": 9012, "pseudo": "Bob", "score": 420, "state": "player"}
],
"winner": {"id": 5678, "pseudo": "Alice"}
}
error
{
"event": "error",
"message": "Room not found"
}
6.2 Client -> server event
selected_card
{
"event": "selected_card",
"player_id": 5678,
"card_id": 123
}
7) Frontend implementation flow
7.1 Create/join strategic room
frontend/components/create-game.vue:
choose mode
strategic,create room (
POST /strategic/create_room) or join existing room,instantiate
new StrategicGame(...),open WebSocket with
useStrategicWebSocket().createSocket(...),route to waiting lobby.
7.2 WebSocket state handling
frontend/composables/useStrategicWebSocket.ts maps server events into Pinia/game state:
game_context: sets players count, current player id, deck name,player_join: replaces local players list,game_start: routes to/strategic-game-board,round_start: sets round data + replaces current user hand,round_result: updates scores and modal state,game_finish: marks statefinished.
7.3 Board page
frontend/pages/strategic-game-board.vue:
controls board layout and card play,
shows round start/result modal,
sends played card via
playStrategicCard(cardId).
8) Persistence and analytics
8.1 Game records
At game start/end:
create_game_record(...)callsadd_game(room),finish_game_record(...)callsupdate_game(room, "finish").
8.2 Leaderboard records
At finish:
non-tutorial and non-bot players are persisted with
mode='strategic'.
8.3 Read APIs using strategic filter
GET /score/leaderboard?strategic=trueGET /score/leaderboard/now?strategic=trueGET /playedGame?strategic=trueGET /playedGame/now?strategic=true
9) Local run and test
9.1 Start services (workspace tasks)
Recommended:
run task EarthPulse - Start Frontend and Backend
Or separately:
Database task
Backend task
Frontend task
Moderation API task
9.2 Manual backend launch
cd apiserver
uvicorn main:app --host localhost --reload --port 8000 --log-config logging-loacl.yaml
9.3 Manual frontend launch
cd frontend
npm run dev:fast
9.4 Manual moderation API launch
cd api-moderation
uvicorn main:app --host localhost --reload --port 8001
9.5 Strategic load test script
A dedicated stress script exists in tests/test.py:
configurable mode:
multiplayer | solo | tutorialcreates strategic rooms through HTTP,
connects WS clients,
auto-plays random cards,
collects latency/error metrics.
Run example:
cd tests
python test.py
10) Current implementation notes (important)
Room id size: generated ids are now 6-digit numeric strings (
100000-999999).Round duration mismatch:
service constant defines
50s,room default is
20s,runtime currently uses room field (
20s) unless overridden.
Token WS endpoint exists (
/ws/{room_id}/{pseudo}/{token}), but current strategic router delegates withcurrent_user=None.Frontend type gap:
frontend/types/WebSocketEvent.tsis mostly classic-mode typing; strategic flow currently relies on runtime fields (anyin several branches).``round_indicators`` field is read in frontend, but not included in current
round_startpayload preparation.
11) Suggested maintenance checklist
When updating Strategic mode, validate all of these together:
REST create-room schema (
StrategicRoomSettings)WebSocket event schema in service + frontend composable
scoring constants and formulas
StrategicRoomdefaults (round duration/rounds)leaderboard mode persistence (
mode='strategic')cleanup behavior for stale rooms
load test script expectations in
tests/test.py
12) File map
Backend:
apiserver/main.pyapiserver/apiserver/routers/strategic_game.pyapiserver/apiserver/controllers/strategic_game_controller.pyapiserver/apiserver/services/strategic_game_service.pyapiserver/apiserver/models/strategic_room.pyapiserver/apiserver/models/strategic_player.pyapiserver/apiserver/tasks/strategic_room_cleaner.pyapiserver/apiserver/types/strategic_game_type.py
Frontend:
frontend/components/create-game.vuefrontend/components/lobby-wait-players.vuefrontend/pages/strategic-game-board.vuefrontend/components/strategic-game-card.vuefrontend/components/strategic-game-board-card.vuefrontend/composables/useStrategicWebSocket.tsfrontend/class/StrategicGame.tsfrontend/class/StrategicPlayer.tsfrontend/store/main.ts
Tests:
tests/test.py