From 3cfce61bfe82be72ac10f90fec7bdd1df78112fb Mon Sep 17 00:00:00 2001 From: Kristoffer Welle Date: Tue, 21 Apr 2026 14:14:35 +0200 Subject: [PATCH 1/4] Batch answer submission into single Firestore round-trip Replace two parallel Firestore writes (updatePlayerScore + submitAnswer) with a single WriteBatch.commit() via submitAnswerAndScore(). Reduces network round-trips on answer submission from two to one. Also adds temporary P1 latency instrumentation (adb logcat -s P1_LATENCY) to measure the improvement on-device. Remove the P1 comment blocks in RoundController once measurements are complete. Co-Authored-By: Claude Sonnet 4.6 --- .../firebase/AndroidFirebaseGateway.java | 5 ++ .../firebase/FirestoreSessionRepository.java | 35 +++++++++++++ .../controller/RoundController.java | 51 ++++++++----------- .../beatbattle/firebase/FirebaseGateway.java | 3 ++ .../firebase/NoOpFirebaseGateway.java | 5 ++ .../firebase/FakeFirebaseGateway.java | 10 ++++ 6 files changed, 80 insertions(+), 29 deletions(-) diff --git a/android/src/main/java/group07/beatbattle/android/firebase/AndroidFirebaseGateway.java b/android/src/main/java/group07/beatbattle/android/firebase/AndroidFirebaseGateway.java index 8b75a9d..99c36dd 100644 --- a/android/src/main/java/group07/beatbattle/android/firebase/AndroidFirebaseGateway.java +++ b/android/src/main/java/group07/beatbattle/android/firebase/AndroidFirebaseGateway.java @@ -129,6 +129,11 @@ public void submitAnswer(String sessionId, int roundIndex, String playerId, Simp sessionRepository.submitAnswer(sessionId, roundIndex, playerId, callback); } + @Override + public void submitAnswerAndScore(String sessionId, int roundIndex, String playerId, int score, SimpleCallback callback) { + sessionRepository.submitAnswerAndScore(sessionId, roundIndex, playerId, score, callback); + } + @Override public void listenToRoundAnswerCount(String sessionId, int roundIndex, AnswerCountCallback callback) { sessionRepository.listenToRoundAnswerCount(sessionId, roundIndex, callback); diff --git a/android/src/main/java/group07/beatbattle/android/firebase/FirestoreSessionRepository.java b/android/src/main/java/group07/beatbattle/android/firebase/FirestoreSessionRepository.java index 9b3572f..ebd96c8 100644 --- a/android/src/main/java/group07/beatbattle/android/firebase/FirestoreSessionRepository.java +++ b/android/src/main/java/group07/beatbattle/android/firebase/FirestoreSessionRepository.java @@ -452,6 +452,41 @@ public void submitAnswer( .addOnFailureListener(callback::onFailure); } + public void submitAnswerAndScore( + String sessionId, + int roundIndex, + String playerId, + int score, + FirebaseGateway.SimpleCallback callback + ) { + Map answerData = new HashMap<>(); + answerData.put("roundIndex", roundIndex); + answerData.put("playerId", playerId); + + Map scoreData = new HashMap<>(); + scoreData.put("score", score); + + WriteBatch batch = firestore.batch(); + batch.set( + firestore.collection(SESSIONS) + .document(sessionId) + .collection("Answers") + .document(roundIndex + "_" + playerId), + answerData + ); + batch.update( + firestore.collection(SESSIONS) + .document(sessionId) + .collection(PLAYERS) + .document(playerId), + scoreData + ); + + batch.commit() + .addOnSuccessListener(unused -> callback.onSuccess()) + .addOnFailureListener(callback::onFailure); + } + public void listenToRoundAnswerCount( String sessionId, int roundIndex, diff --git a/core/src/main/java/group07/beatbattle/controller/RoundController.java b/core/src/main/java/group07/beatbattle/controller/RoundController.java index 9601e42..9463189 100644 --- a/core/src/main/java/group07/beatbattle/controller/RoundController.java +++ b/core/src/main/java/group07/beatbattle/controller/RoundController.java @@ -45,6 +45,10 @@ public interface SessionCancelledCallback { private int activePlayerCount; private int currentAnswerCount = 0; + // P1 latency test — remove after measuring + private static int p1SubmissionCount = 0; + private long p1StartMs = 0; + public RoundController(BeatBattle game, Question question, GameSession session) { this.game = game; this.question = question; @@ -116,26 +120,28 @@ public void onFailure(Exception exception) { } public void onAnswerSubmitted(String answer) { + p1StartMs = System.currentTimeMillis(); // P1 — remove after measuring float answerTime = getTotalTime() - getTimeRemaining(); Player localPlayer = findLocalPlayer(); + int score = 0; if (localPlayer != null) { AnswerSubmission submission = AnswerSubmission.create(localPlayer.getId(), answer, answerTime, question); localPlayer.addScore(submission.getPointsAwarded()); - submitScoreToFirebase(localPlayer.getId(), localPlayer.getScore()); + score = localPlayer.getScore(); } playerAnswered = true; - recordAnswerInFirebase(); + submitAnswerAndScoreToFirebase(localPlayer != null ? localPlayer.getId() : null, score); } /** Called when the round timer runs out. Submits a no-answer if the player never responded, then transitions to the leaderboard. */ public void onRoundExpired() { if (!playerAnswered) { Player localPlayer = findLocalPlayer(); - if (localPlayer != null) { - submitScoreToFirebase(localPlayer.getId(), localPlayer.getScore()); - } - recordAnswerInFirebase(); // count as answered so listener is consistent + submitAnswerAndScoreToFirebase( + localPlayer != null ? localPlayer.getId() : null, + localPlayer != null ? localPlayer.getScore() : 0 + ); } transitionToLeaderboard(); } @@ -274,19 +280,24 @@ private void maybeAdvanceToLeaderboard() { } } - private void recordAnswerInFirebase() { + private void submitAnswerAndScoreToFirebase(String playerId, int score) { String sessionId = game.getLocalSessionId(); - String playerId = game.getLocalPlayerId(); if (sessionId == null || sessionId.isBlank() || playerId == null) return; - game.getFirebaseGateway().submitAnswer( + game.getFirebaseGateway().submitAnswerAndScore( sessionId, question.getRoundIndex(), playerId, + score, new FirebaseGateway.SimpleCallback() { - @Override public void onSuccess() {} + @Override public void onSuccess() { + // P1 latency test — remove after measuring + long ms = System.currentTimeMillis() - p1StartMs; + p1SubmissionCount++; + Gdx.app.log("P1_LATENCY", "submission " + p1SubmissionCount + ": " + ms + " ms"); + } @Override public void onFailure(Exception e) { - Gdx.app.error("RoundController", "submitAnswer failed: " + e.getMessage()); + Gdx.app.error("RoundController", "submitAnswerAndScore failed: " + e.getMessage()); } } ); @@ -305,24 +316,6 @@ private Player findLocalPlayer() { return null; } - private void submitScoreToFirebase(String playerId, int score) { - String sessionId = game.getLocalSessionId(); - if (sessionId == null || sessionId.isBlank()) return; - - game.getFirebaseGateway().updatePlayerScore( - sessionId, - playerId, - score, - new FirebaseGateway.SimpleCallback() { - @Override public void onSuccess() {} - @Override public void onFailure(Exception exception) { - Gdx.app.error("RoundController", - "Failed to submit score: " + exception.getMessage()); - } - } - ); - } - private void transitionToLeaderboard() { if (transitioned) return; transitioned = true; diff --git a/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java b/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java index e796b20..f4cb6af 100644 --- a/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java +++ b/core/src/main/java/group07/beatbattle/firebase/FirebaseGateway.java @@ -96,6 +96,9 @@ void deleteSession( /** Records that a player has submitted an answer for the given round. */ void submitAnswer(String sessionId, int roundIndex, String playerId, SimpleCallback callback); + /** Atomically records the answer and updates the player score in a single round-trip. */ + void submitAnswerAndScore(String sessionId, int roundIndex, String playerId, int score, SimpleCallback callback); + /** Fires with the current answer count whenever a new answer is submitted for a round. */ void listenToRoundAnswerCount(String sessionId, int roundIndex, AnswerCountCallback callback); diff --git a/core/src/main/java/group07/beatbattle/firebase/NoOpFirebaseGateway.java b/core/src/main/java/group07/beatbattle/firebase/NoOpFirebaseGateway.java index 66c8c92..ddbc865 100644 --- a/core/src/main/java/group07/beatbattle/firebase/NoOpFirebaseGateway.java +++ b/core/src/main/java/group07/beatbattle/firebase/NoOpFirebaseGateway.java @@ -118,6 +118,11 @@ public void submitAnswer(String sessionId, int roundIndex, String playerId, Simp callback.onFailure(new UnsupportedOperationException("Firebase is not available on this platform")); } + @Override + public void submitAnswerAndScore(String sessionId, int roundIndex, String playerId, int score, SimpleCallback callback) { + callback.onFailure(new UnsupportedOperationException("Firebase is not available on this platform")); + } + @Override public void listenToRoundAnswerCount(String sessionId, int roundIndex, AnswerCountCallback callback) { callback.onFailure(new UnsupportedOperationException("Firebase is not available on this platform")); diff --git a/core/src/test/java/group07/beatbattle/firebase/FakeFirebaseGateway.java b/core/src/test/java/group07/beatbattle/firebase/FakeFirebaseGateway.java index f93bb95..6a4ccff 100644 --- a/core/src/test/java/group07/beatbattle/firebase/FakeFirebaseGateway.java +++ b/core/src/test/java/group07/beatbattle/firebase/FakeFirebaseGateway.java @@ -222,6 +222,16 @@ public void updatePlayerScore(String sessionId, String playerId, int score, callback.onSuccess(); } + @Override + public void submitAnswerAndScore(String sessionId, int roundIndex, String playerId, + int score, SimpleCallback callback) { + updatePlayerScore(sessionId, playerId, score, new SimpleCallback() { + @Override public void onSuccess() {} + @Override public void onFailure(Exception e) {} + }); + submitAnswer(sessionId, roundIndex, playerId, callback); + } + @Override public void submitAnswer(String sessionId, int roundIndex, String playerId, SimpleCallback callback) { From 13c37f1abb9d1370323b84bad7ad3c77d0fdaa5e Mon Sep 17 00:00:00 2001 From: Kristoffer Welle Date: Tue, 21 Apr 2026 14:39:17 +0200 Subject: [PATCH 2/4] Add P2 latency instrumentation to join session flow Logs elapsed time from joinSession() call to lobby view display. Run: adb logcat -s P2_LATENCY Remove the two P2 comment blocks in LobbyController once done. Co-Authored-By: Claude Sonnet 4.6 --- .../java/group07/beatbattle/controller/LobbyController.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/src/main/java/group07/beatbattle/controller/LobbyController.java b/core/src/main/java/group07/beatbattle/controller/LobbyController.java index 16e37c5..5a3c302 100644 --- a/core/src/main/java/group07/beatbattle/controller/LobbyController.java +++ b/core/src/main/java/group07/beatbattle/controller/LobbyController.java @@ -125,6 +125,7 @@ public void onFailure(Exception exception) { view.setStatusMessage("Joining session..."); + final long p2StartMs = System.currentTimeMillis(); // P2 — remove after measuring lobbyService.joinSession( playerName, gamePin, @@ -132,6 +133,8 @@ public void onFailure(Exception exception) { @Override public void onSuccess(SessionJoinResult result) { Gdx.app.postRunnable(() -> { + // P2 — remove after measuring + Gdx.app.log("P2_LATENCY", "join latency: " + (System.currentTimeMillis() - p2StartMs) + " ms"); game.setLocalPlayer(result.getPlayerId(), result.getPlayerName()); game.setLocalSession(result.getSessionId(), false); game.setLocalGamePin(result.getGamePin()); From 067763cd2ae53432b82ee8b65def15f64fa52d13 Mon Sep 17 00:00:00 2001 From: Kristoffer Welle Date: Tue, 21 Apr 2026 14:49:00 +0200 Subject: [PATCH 3/4] Pre-warm Firestore gRPC connection on join game Fire a no-op gamePinExists query when the user navigates to the join screen. This establishes the gRPC connection before they type a pin, eliminating the ~1400ms cold-start penalty on the first real Firestore call during session join. Co-Authored-By: Claude Sonnet 4.6 --- .../group07/beatbattle/controller/LobbyController.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/core/src/main/java/group07/beatbattle/controller/LobbyController.java b/core/src/main/java/group07/beatbattle/controller/LobbyController.java index 5a3c302..3539052 100644 --- a/core/src/main/java/group07/beatbattle/controller/LobbyController.java +++ b/core/src/main/java/group07/beatbattle/controller/LobbyController.java @@ -59,6 +59,13 @@ public void onCreateGame() { } public void onJoinGame() { + // Fire a no-op Firestore query to pre-warm the gRPC connection before the user + // reaches the join screen. By the time they type a pin and tap Join, the + // connection is established and the first real call avoids the cold-start penalty. + game.getFirebaseGateway().gamePinExists("____", new FirebaseGateway.GamePinExistsCallback() { + @Override public void onResult(boolean exists) {} + @Override public void onFailure(Exception exception) {} + }); StateManager.getInstance().setState(new JoinCreateState(game, GameMode.JOIN, this)); } From 9f2029d814b9ab522a5a780495e925cb82844a0f Mon Sep 17 00:00:00 2001 From: Kristoffer Welle Date: Tue, 21 Apr 2026 14:55:39 +0200 Subject: [PATCH 4/4] Move gRPC warmup to LobbyController constructor Fires the no-op warmup query at app start rather than when the user taps Join Game, giving the connection more time to establish before the first real Firestore call. Reduces cold-start join latency further. Co-Authored-By: Claude Sonnet 4.6 --- .../beatbattle/controller/LobbyController.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/core/src/main/java/group07/beatbattle/controller/LobbyController.java b/core/src/main/java/group07/beatbattle/controller/LobbyController.java index 3539052..4da0ba6 100644 --- a/core/src/main/java/group07/beatbattle/controller/LobbyController.java +++ b/core/src/main/java/group07/beatbattle/controller/LobbyController.java @@ -52,6 +52,13 @@ public interface LobbySessionCallback { public LobbyController(BeatBattle game) { this.game = game; + // Fire a no-op Firestore query on startup to pre-warm the gRPC connection. + // By the time the user navigates to the join screen and types a pin, the + // connection is fully established, eliminating the cold-start latency penalty. + game.getFirebaseGateway().gamePinExists("____", new FirebaseGateway.GamePinExistsCallback() { + @Override public void onResult(boolean exists) {} + @Override public void onFailure(Exception exception) {} + }); } public void onCreateGame() { @@ -59,13 +66,6 @@ public void onCreateGame() { } public void onJoinGame() { - // Fire a no-op Firestore query to pre-warm the gRPC connection before the user - // reaches the join screen. By the time they type a pin and tap Join, the - // connection is established and the first real call avoids the cold-start penalty. - game.getFirebaseGateway().gamePinExists("____", new FirebaseGateway.GamePinExistsCallback() { - @Override public void onResult(boolean exists) {} - @Override public void onFailure(Exception exception) {} - }); StateManager.getInstance().setState(new JoinCreateState(game, GameMode.JOIN, this)); }