A Flutter mobile client talks to a Spring Boot backend over STOMP/WebSockets. The backend ran on a VM on my Proxmox homelab (the desktop in my room), exposed through a Cloudflare Tunnel.
The development story is a separate post. This document details the system architecture, state boundaries, and failure modes.
Subsystem Boundaries
I engineered the game state machine, the Dart engine port, the game service layer, the WebSocket security handshake (JwtWebSocketInterceptor), the AI orchestration pipeline with simulated thinking delays, matchmaking, in-game chat, and the k6 load testing suite. On the client, I built the Flutter core architecture: Riverpod state providers, the STOMP transport with automatic subscription rehydration, dynamic server URL switching, the audio engine, and the game screen with turn-timer heartbeat animations.
Teammates handled REST authentication endpoints, the social/friends graph, Redis pub/sub listener wiring, core Prometheus/Loki/Grafana k8s manifests, and the Java engine’s legal move generation rules (including forced jumps). I authored the Ollama Kubernetes deployment (Deployment, PVC, Service) and assisted in the k3s cluster migration.
Architecture
Storage is partitioned across three engines based on access patterns:
- PostgreSQL: Relational data (users, credentials, friendships, general chat).
- Redis: Ephemeral state (live matches, active lobbies, chat lists, user session pointers).
- MongoDB: Match history written strictly once at game completion.
The rules module (game-engine) manages board representations, piece mutations, promotions, and win conditions. However, the domain model directly couples to spring-data-commons: GameState and Board carry @PersistenceCreator annotations so Spring Data can hydrate documents straight from MongoDB. This trades domain isolation to eliminate mapping boilerplate.
Post-match statistics are isolated from the critical path: StatisticsServiceImpl.updateStatsAfterGame is annotated with @Async("taskExecutor"), preventing cold disk writes in MongoDB from delaying the real-time match termination broadcast.
Ephemeral State & Lazy Pruning
While a match is active, state transitions occur entirely in memory within Redis:
game:{id}: Serialized match document (board, history, player IDs) with a rolling 5-minute TTL refreshed on every legal move.user:{id}:active_game: String pointer enforcing a single concurrent match per player, refreshed alongsidegame:{id}.chat:game:{id}: Redis List of messages with a 24-hour TTL, deliberately outliving abandoned matches.lobby:open_games: A Redis Set containing open match UUIDs. This key has no TTL.
Because lobby:open_games holds keys that expire independently under game:{id}, inactive lobbies produce dangling references:
- Client Timer vs. Server Expiry: To mitigate this, the Flutter client runs a local lobby timer calibrated to slightly under 5 minutes, canceling the lobby and kicking the host back to the main menu before the backend key expires. However, mobile clients are ungraceful: locking the phone, backgrounding the OS, or dropping cellular signal halts the timer before the cancellation frame can send. The server’s 5-minute TTL lapses regardless, leaving an orphaned UUID in
lobby:open_games. - Opportunistic Garbage Collection: Rather than running scheduled scans,
fetchGamesBatch()implements self-healing reads. When fetching lobbies, it runs amultiGet()across all IDs; keys that returnnull(expired via their 5-minute TTL) are gathered and batch-evicted viaopsForSet().remove(). - Lockout Protection: When a player initiates a match,
isUserPlaying()validatesgameTemplate.hasKey("game:" + gameId). If the game expired, it inline-evicts the orphaned user pointer to prevent permanent account lockouts.
Matchmaking & Joining
Matchmaking prioritizes zero-coordination throughput over strict FIFO queueing:
- Random Draw & Unhandled Corrupted Keys: An incoming player runs
SRANDMEMBERagainstlobby:open_games. If empty, they create a game and wait; if populated, they attempt to join. If the drawn ID has expired in Redis, the backend throws an unhandled exception immediately on attempt 0 and aborts the request. Because this crash path never callssrem, the dead ID remains in the set, causing subsequent quick-match attempts to draw the exact same dead key until someone visits the lobby browser and indirectly triggers the batch repair. - Subscription Race: The client registers STOMP subscriptions before dispatching join frames. Because STOMP lacks request-response semantics, frames arriving before subscription confirmation are silently dropped.
- In-Memory Broker Boundary: The backend was designed to run as several replicas: broadcasts fan out across instances using discrete Redis channels (
game-updates,game-over,game-chat,lobby-updates). Private user notifications (convertAndSendToUser), however, rely on Spring’s in-memorySimpleBroker, so with more than one replica a directed message would fail whenever the recipient is connected to another pod. In production the backend ran as one replica on one node, so this limit was never hit.
Move Validation & State Authority
The rules exist twice, in Java on the server and in Dart on the client, with strict server authority:
- Zero Optimistic UI: The client engine runs locally solely to calculate reachable squares and render tap targets. When a player taps,
submitMove()emits the STOMP frame and clears selection state. The local board updates only when the server broadcasts the applied move back on/topic/game/{id}. - WebSocket Security Handshake: Because standard HTTP session security does not bridge to STOMP over SockJS, a custom
JwtWebSocketInterceptorinterceptsStompCommand.CONNECTframes, extracts and validates theAuthorization: Bearer <jwt>header, and binds the principal to the STOMP session. - Concurrency Model: Move execution runs a read-modify-write cycle (
getGame->applyMove->saveGame) without RedisWATCHor distributed locks. Concurrency safety relies entirely on sequential turn validation: only one player can legally write at any moment.
Disconnects & The Silent Surrender Bug
Disconnect events and client reconnection logic produce a critical edge-case conflict:
- Client Auto-Rehydration: If a network blip drops the socket, Flutter’s
StompServicereconnects and replays an internal registry of active subscriptions (_activeSubscriptions). However, it does not fetch the latest board state; any move broadcast during the drop window is permanently lost. - The Disconnect Trap: If the socket closes completely,
WebSocketPresenceEventListenerauto-surrenders the match to prevent orphaned state. - The Silent Game-Over: The disconnect listener calls
gameService.surrenderGame()directly, bypassingGameSocketController.notifyGameOver(). As a result, the match is persisted to Mongo and cleared from Redis, but no game-over frame is published to the connected opponent, leaving them stranded on an active board waiting for a move that will never arrive.
AI Opponent & Thinking Engine
The AI integration runs asynchronously to avoid blocking server worker threads:
- Dynamic Provider Strategy: The backend supports pluggable providers via an
ai.providerconfiguration. - Engine Fallbacks: When calling external models (Medium/Hard),
AiOrchestratortraps network timeouts and schema failures, falling back to a random legal engine move (getRandomMove). However,GameServiceImpl.triggerAiMove()wraps the entire dispatch in an emptycatch (Exception e) {}block that returnsnull; an uncaught engine or serialization bug causes the game to hang silently. - Simulated Think Delay: Fast local models and deterministic moves executed in under 50ms, jarring user pacing.
ensureMinimumDelay()enforces a configurable delay (ai.min-think-time-ms) to simulate real human consideration before dispatching moves.
Flutter Client Architecture
The mobile app is organized by feature modules under lib/features/:
- Runtime Endpoint Configuration: Replaced static compile-time constants with
BackendSetupScreenand Riverpod controllers, allowing the APK to target local LAN, Docker, or Cloudflare Tunnel environments at launch. - Turn Timer & Heartbeat Glow: Enforces turn deadlines client-side with a custom pulsating heartbeat animation when time drops below threshold, triggering automatic surrender on expiration.
- Audio Layer: Centralized
SoundServicemaps discrete game events (moves, captures, promotions, game over) to dedicated sound assets and haptic feedback profiles.
Load Testing & Benchmarks
Performance testing was executed against a single-node k3s cluster using k6.
Because k6 lacks native STOMP protocol support, I developed a custom test harness (k6/loadtest.js) that manually constructs raw byte-level frames: formatting headers, injecting JWTs, tracking subscription IDs, and appending explicit null byte terminators (\x00).
The scenario simulated complete player lifecycles (authentication, lobby polling, board subscriptions, move exchanges, and in-game chat). The single node reached saturation at 6,000 concurrent active STOMP sessions before encountering OS socket and thread pool constraints.