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 (rooms dict) 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.py

  • apiserver/apiserver/controllers/strategic_game_controller.py

  • apiserver/apiserver/services/strategic_game_service.py

  • apiserver/apiserver/models/strategic_room.py

  • apiserver/apiserver/models/strategic_player.py

Core frontend modules:

  • frontend/components/create-game.vue

  • frontend/composables/useStrategicWebSocket.ts

  • frontend/class/StrategicGame.ts

  • frontend/class/StrategicPlayer.ts

  • frontend/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_players must be in {2,3,4} depending on the selected mode.

  • mode determines game behavior (e.g., "strategic", "tutorial", "solo").

  • room_id is 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_name

  • players: players

  • lifecycle: state in waiting -> playing -> finish

  • deck snapshot: deck_snapshot, deck_card_ids

  • round state: round_index, current_indicator, round_indicators, used_indicator_ids

  • timing/rules: round_duration_seconds, hand_size, max_rounds

  • metadata: deck_indicators_meta, indicator_bounds

  • safety: lock (asyncio.Lock) for concurrent WS mutations

3.2 StrategicPlayer

Transient per-player data:

  • identity: id, pseudo

  • socket: websocket

  • game state: hand_card_ids, picked_card_id, score

  • flags: 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) via get_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_snapshot built 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(...):

  1. normalizes players count using tutorial/solo flags,

  2. validates deck via DeckService.validate_deck_usage("strategic", ...),

  3. loads cached deck bundle,

  4. loads indicator metadata,

  5. snapshots cards,

  6. allocates unique room id,

  7. stores room in global rooms dict.

4.2 Player join

handle_websocket(...):

  1. validates pseudo with toxicity checker,

  2. accepts socket,

  3. validates room joinability (exists, not full, state=waiting),

  4. creates player + appends to room,

  5. in solo mode, auto-injects LuckyBot,

  6. sends game_context, broadcasts player_join.

4.3 Auto-start

When len(room.players) == room.max_players and state is waiting, server starts game:

  • state -> playing

  • round index initialized to 1

  • indicator bounds computed

  • initial hands dealt

  • game DB record created

  • game_start broadcast

  • first round_start emitted per player (with own hand)

4.4 Round resolution loop

For each round:

  1. clients send selected_card (player/card ids),

  2. server registers picks,

  3. in solo mode bot auto-picks,

  4. when all active players picked -> resolve_round(...),

  5. server broadcasts round_result,

  6. refills hands, increments round index,

  7. starts next round or finishes game.

4.5 Finish and cleanup

On game end:

  • state -> finish

  • game record updated to finish

  • leaderboard scores persisted (except tutorial / bots)

  • game_finish broadcast

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 -> 40

  • 0.5 <= percentile < 0.8 -> Medium risk -> 100

  • percentile < 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 + bonus

  • Others: bonus only

  • Draw: winner = null, is_draw = true

All players accumulate cumulative score across rounds.

5.4 Round/game limits

  • default strategic max rounds: 4

  • tutorial 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 state finished.

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).

7.4 Shared store

frontend/store/main.ts keeps strategic mode state under:

  • strategicGame,

  • error popup states,

  • leaderboard/general shared UI flags.


8) Persistence and analytics

8.1 Game records

At game start/end:

  • create_game_record(...) calls add_game(room),

  • finish_game_record(...) calls update_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=true

  • GET /score/leaderboard/now?strategic=true

  • GET /playedGame?strategic=true

  • GET /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 | tutorial

  • creates 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)

  1. Room id size: generated ids are now 6-digit numeric strings (100000-999999).

  2. Round duration mismatch:

    • service constant defines 50s,

    • room default is 20s,

    • runtime currently uses room field (20s) unless overridden.

  3. Token WS endpoint exists (/ws/{room_id}/{pseudo}/{token}), but current strategic router delegates with current_user=None.

  4. Frontend type gap: frontend/types/WebSocketEvent.ts is mostly classic-mode typing; strategic flow currently relies on runtime fields (any in several branches).

  5. ``round_indicators`` field is read in frontend, but not included in current round_start payload 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

  • StrategicRoom defaults (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.py

  • apiserver/apiserver/routers/strategic_game.py

  • apiserver/apiserver/controllers/strategic_game_controller.py

  • apiserver/apiserver/services/strategic_game_service.py

  • apiserver/apiserver/models/strategic_room.py

  • apiserver/apiserver/models/strategic_player.py

  • apiserver/apiserver/tasks/strategic_room_cleaner.py

  • apiserver/apiserver/types/strategic_game_type.py

Frontend:

  • frontend/components/create-game.vue

  • frontend/components/lobby-wait-players.vue

  • frontend/pages/strategic-game-board.vue

  • frontend/components/strategic-game-card.vue

  • frontend/components/strategic-game-board-card.vue

  • frontend/composables/useStrategicWebSocket.ts

  • frontend/class/StrategicGame.ts

  • frontend/class/StrategicPlayer.ts

  • frontend/store/main.ts

Tests:

  • tests/test.py