In a perfect world, every Cabo game ends with a winner, a handshake, and a clean session close. In the real world, players force-quit the app, their phone dies, they switch to a WhatsApp call that takes 20 minutes, or they just rage-quit after a bad burn.

None of these produce a “I’m leaving” message. The WebSocket connection just drops. The server receives a close frame — or, more commonly, receives nothing at all. The game is stuck waiting for a player who’s already gone.

The server is the source of truth

The Cabo server tracks every action in real time. Each turn has a timer. When the timer expires without a move, the server auto-skips the player and assigns a -5 XP penalty. This handles the “player is AFK but still connected” case.

But what about the “player is gone entirely” case? The server detects this at the WebSocket level. When a client disconnects — whether gracefully (close frame) or abruptly (connection reset) — the WebSocket handler fires:

SessionService.java (simplified)
java
public void handleDisconnect(String sessionId, String uid) {
    var session = getSession(sessionId);
    var player = session.getPlayer(uid);

    if (session.isGameEnded()) return; // game already over

    // Mark player as disconnected but don't end the game yet.
    // Other players are still in — they continue playing.
    player.setDisconnected(true);
    player.setDisconnectedAt(Instant.now());

    // Apply the one-shot abandon penalty
    xpRulebook.onPlayerLeft(sessionId, uid);
}

The abandoned player gets a one-shot -50 XP penalty. “One-shot” means it’s guarded against duplicate application — no matter how many times the disconnect handler fires, the penalty is applied exactly once per session.

The one-shot guard

Network events can fire multiple times. A WebSocket disconnect might trigger both onClose and onError. A server restart might clean up stale sessions and re-process a disconnect. The abandon penalty must be idempotent:

CaboXpRulebook.java
java
private final Set<String> abandonedUids = ConcurrentHashMap.newKeySet();

public void onPlayerLeft(String sessionId, String uid) {
    if (isBot(uid)) return;
    // One-shot guard: only apply -50 once per (session, uid)
    if (!abandonedUids.add(sessionId + "-" + uid)) return;
    tracker.updateXp(sessionId, uid, CABO_ABANDON); // -50
}

The ConcurrentHashMap.newKeySet() is thread-safe and fast. The composite key sessionId-uidensures a player can’t be penalized twice in the same match, even if the disconnect event fires multiple times.

What about the game itself?

The game doesn’t end when a player abandons. The remaining players continue. Bot players fill in if needed. The game only ends when:

  1. A player calls Cabo and the round completes normally.
  2. All human players disconnect (the “awkward end” — game is terminated).
  3. The turn timer expires for every remaining player (extremely rare).

After the game ends, handleGameEnd fires. It archives the session to MongoDB, writes XP sessions to Firestore, and cleans up in-memory state:

SessionSchedulerService.java (simplified)
java
public void handleGameEnd(String sessionId) {
    var session = getSession(sessionId);
    var winnerUids = session.determineWinners();

    // Apply win/loss XP to all human players
    xpRulebook.onMatchEnd(session, winnerUids);

    // Finalize and write to Firestore
    var finalStates = tracker.submitXp(sessionId);
    writer.writeMatch(session, finalStates, winnerUids);

    // Archive to MongoDB and clean up in-memory state
    archiveService.archive(session);
    removeFromMemory(sessionId);
}

Abandoned players are skipped in the win/loss calculation (they already have -50 from the abandon penalty). Active players get their normal win/loss XP.

What about solo games?

Solo games (Trek, Recall, Wordgrid) don’t have a server managing the session. The mobile app computes XP locally and POSTs it on the end screen. Force-quitting the app mid-game produces no session record at all — no telemetry, no XP, no Firestore write.

This is intentional and acceptable. Solo games have a clean end state: either you complete the puzzle (win) or you tap the exit confirmation (loss). If you force-quit, neither outcome is recorded. The session simply never happened. For a casual game, this is fine — the worst case is losing 30 seconds of progress, not affecting other players.

The missing “I’m leaving” signal

In an ideal world, the app would send a “leaving” message to the server before disconnecting. Flutter provides AppLifecycleState.detached and the dispose() method. But neither is guaranteed:

  • iOS suspends the app immediately when the user force-quits. No lifecycle callback fires.
  • Android may kill the process without calling dispose.
  • Network drops don’t give the client time to send anything.

The server must handle disconnects without a goodbye. This is why onDisconnect() in Firebase RTDB is so important for presence, and why the WebSocket close handler must be the authoritative source for detecting abandonment.

What I’d Tell My Past Self

  1. The server is the source of truth for multiplayer game state. The client goes first for UX responsiveness, but the server validates and finalizes.
  2. Guard all idempotent operations with a one-shot key. Network events fire multiple times. Use ConcurrentHashMap.newKeySet() or equivalent.
  3. Don’t end the game when one player abandons. Bots fill in, remaining players continue. Only end when the game logic says so or all humans leave.
  4. Force-quit produces no session record in solo games. Accept this. The worst case is losing a few seconds of progress. It’s not worth building a heart-beat system for.
  5. Never trust the client’s “I’m leaving” message. Design the server to detect absence, not rely on the client’s farewell.