From 30dff46e6608d214a0a8d530adbca744bb0b578a Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:06:34 +0000 Subject: [PATCH 1/2] perf(backend): reuse GameStateSchema in broadcasts Reduces redundant serialization by computing the full GameStateSchema once and passing it to player-specific view generation, instead of re-serializing for every player. Addresses a mutation bug where modifying the reused schema in-place caused side effects; now uses model_copy() to ensure safety. Co-authored-by: WeixuanZ <39925558+WeixuanZ@users.noreply.github.com> --- backend/app/api/routers/rooms.py | 7 ++++++- backend/app/models/game.py | 18 +++++++++++------- backend/app/services/game_service.py | 8 ++++++-- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/backend/app/api/routers/rooms.py b/backend/app/api/routers/rooms.py index c9850a0..14c714e 100644 --- a/backend/app/api/routers/rooms.py +++ b/backend/app/api/routers/rooms.py @@ -26,8 +26,13 @@ async def broadcast_filtered_states(room_id: str, service: GameService): # Fetch presence for all players once presence_map = await service.get_all_player_presence(room_id, list(game.players.keys())) + # Optimize: Pre-calculate full schema once + full_schema = game.to_schema() + async def get_view(player_id: str): - return await service.get_player_view(game, player_id, presence_map) + return await service.get_player_view( + game, player_id, presence_map, full_schema=full_schema + ) await websocket_manager.broadcast_filtered_game_states(room_id, get_view) diff --git a/backend/app/models/game.py b/backend/app/models/game.py index b43d2d9..ada3281 100644 --- a/backend/app/models/game.py +++ b/backend/app/models/game.py @@ -152,12 +152,16 @@ def to_schema(self) -> GameStateSchema: ) # ===== View Logic ===== - def get_view_for_player(self, viewer_id: str) -> GameStateSchema: + def get_view_for_player( + self, viewer_id: str, full_schema: GameStateSchema | None = None + ) -> GameStateSchema: """ Create a filtered view of the game state for a specific player. Hides roles and actions based on game rules. """ - full_schema = self.to_schema() + if full_schema is None: + full_schema = self.to_schema() + is_game_over = full_schema.phase == GamePhase.GAME_OVER viewer = self.players.get(viewer_id) @@ -258,11 +262,11 @@ def get_view_for_player(self, viewer_id: str) -> GameStateSchema: self._state, pid ) - full_schema.players = filtered_players - # Never expose the raw seer reveal map - full_schema.seer_reveals = {} - - return full_schema + # Create a copy with filtered players and clean sensitive data + return full_schema.model_copy(update={ + "players": filtered_players, + "seer_reveals": {} + }) def auto_balance_roles(self): """Automatically set default role distribution based on player count.""" diff --git a/backend/app/services/game_service.py b/backend/app/services/game_service.py index b45c062..7657b1f 100644 --- a/backend/app/services/game_service.py +++ b/backend/app/services/game_service.py @@ -38,11 +38,15 @@ async def get_all_player_presence(self, room_id: str, player_ids: list[str]) -> return {pid: presence_results[i] is not None for i, pid in enumerate(player_ids)} async def get_player_view( - self, game: Game, player_id: str, presence_map: dict[str, bool] | None = None + self, + game: Game, + player_id: str, + presence_map: dict[str, bool] | None = None, + full_schema: GameStateSchema | None = None, ) -> GameStateSchema: """Return game state with other players' roles hidden unless revealed.""" # Delegate logic to model - view = game.get_view_for_player(player_id) + view = game.get_view_for_player(player_id, full_schema=full_schema) player_ids = list(view.players.keys()) # Optimize: if no players, skip redis From 1fe8052d4dbb8c98f066e776342b0d9ee23ed8e4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 13:11:26 +0000 Subject: [PATCH 2/2] perf(backend): reuse GameStateSchema in broadcasts Reduces redundant serialization by computing the full GameStateSchema once and passing it to player-specific view generation, instead of re-serializing for every player. Addresses a mutation bug where modifying the reused schema in-place caused side effects; now uses model_copy() to ensure safety. Also applies Ruff formatting fixes. Co-authored-by: WeixuanZ <39925558+WeixuanZ@users.noreply.github.com> --- backend/app/api/routers/rooms.py | 4 +--- backend/app/models/game.py | 5 +---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/backend/app/api/routers/rooms.py b/backend/app/api/routers/rooms.py index 14c714e..c97f752 100644 --- a/backend/app/api/routers/rooms.py +++ b/backend/app/api/routers/rooms.py @@ -30,9 +30,7 @@ async def broadcast_filtered_states(room_id: str, service: GameService): full_schema = game.to_schema() async def get_view(player_id: str): - return await service.get_player_view( - game, player_id, presence_map, full_schema=full_schema - ) + return await service.get_player_view(game, player_id, presence_map, full_schema=full_schema) await websocket_manager.broadcast_filtered_game_states(room_id, get_view) diff --git a/backend/app/models/game.py b/backend/app/models/game.py index ada3281..d64fa22 100644 --- a/backend/app/models/game.py +++ b/backend/app/models/game.py @@ -263,10 +263,7 @@ def get_view_for_player( ) # Create a copy with filtered players and clean sensitive data - return full_schema.model_copy(update={ - "players": filtered_players, - "seer_reveals": {} - }) + return full_schema.model_copy(update={"players": filtered_players, "seer_reveals": {}}) def auto_balance_roles(self): """Automatically set default role distribution based on player count."""