# Quarkus backend: design See `requirements.md` for what is built; requirement ids (R1.1, …) are referenced below. The behaviour being ported is described in `keycloak-auth`, `game-history` and `game-clock`; this document covers how the Java backend is built. ## Decisions | Decision | Reason | |---|---| | Quarkus 3.40 (latest stable; 4.0 is in beta), Java 25, JVM mode | R1. Java 25 is the current LTS. Native images would need reflection config for the JSON models and buy little for one long-running process. | | A port of the rules to Java (`app.playmancala.engine`) instead of running `mancala.js` in the JVM (GraalJS) | The engine is about 500 lines with no dependencies; a port is simpler to run, debug and test than an embedded JS runtime. The clients keep using the TypeScript engine for display. | | Engine parity is tested against games recorded with the TypeScript engine | R2.2. A Node script plays seeded random games (including illegal moves) with `packages/mancala.js` and writes every move and the resulting game JSON to a fixture; a Java test replays them. Run the script again after changing the rules. | | All game state is owned by one thread, the **game loop** | The Node backend relies on running on one thread: `GameManager`, the stores, matchmaking, live previews and the WebSocket registry have no locks. A single-threaded scheduled executor keeps that model, so the game logic carries over line by line without races. WebSocket events, timers and API reads that touch live games run on it. A few hundred games are far from what one thread handles. | | Blocking I/O never runs on the game loop (except the restore at startup) | Postgres writes run on the recorder's own thread, Valkey writes on a store thread that keeps them in order (a game's `SET` must not overtake its `DEL`), and token checks on a worker. Results that change state are posted back to the loop. Snapshots are copied on the loop, so serialising them elsewhere is safe. | | Plain JDBC with the existing SQL and the existing migration runner (`schema_migrations`, advisory lock) | R5.1. Flyway or Liquibase would have to be baselined on databases the Node backend already migrated. Two migrations and a 40-line runner do not justify it. Hibernate adds nothing for two tables written once per game. | | `quarkus-redis-client` (blocking `RedisDataSource`) for Valkey | Valkey speaks the Redis protocol. Short connect and command timeouts make calls fail fast while Valkey is down, like `enableOfflineQueue: false` did. | | `quarkus-websockets-next` endpoint at `/`, a thin adapter over a framework-free `RtmtServer` | R3.1. The endpoint only forwards open, text and close events to the game loop; `RtmtServer` holds the protocol (auth handshake, timeouts, sockets per user, ping) and is unit-tested with fake sockets and a manual clock. | | jose4j for Keycloak tokens, not `quarkus-oidc` | Tokens arrive in a WebSocket message, not only in an HTTP header, so they are verified in code. jose4j (managed by the Quarkus BOM) fetches and caches the realm's JWKS like `jose` did. The checks are unchanged: RS256, issuer, `sub`, `exp`, `typ = Bearer`, `azp` in `KEYCLOAK_CLIENT_IDS`. | | Explicit JSON models (records) with `@JsonInclude(NON_NULL)` only on optional fields | R3.2, R5. TypeScript leaves out `undefined` but keeps `null`; e.g. `result: null` and `{ game: null }` must stay while `name` and `leftUserId` are left out. | | `DATABASE_URL` becomes `DATABASE_JDBC_URL`, `DATABASE_USERNAME`, `DATABASE_PASSWORD` | R7.1. The JDBC driver does not take `postgres://user:pass@host/db`; parsing it ourselves is not worth it. Compose builds the new variables from the same `MANCALA_DB_PASSWORD`. | | The project is built in `apps/backend-quarkus` and replaces `apps/backend` in the last task | Every commit on the branch has a working backend, and the two can be compared side by side; the final move keeps the path that the Dockerfile, compose and CI refer to. | | `mvn quarkus:dev` debug port moved to 5006 | Quarkus dev mode listens for a debugger on 5005, the backend's port. | ## Layout ``` apps/backend/ pom.xml Dockerfile multi-stage: Maven + Temurin 25 JDK, then Temurin 25 JRE scripts/record-engine-games.js writes the engine parity fixture from packages/mancala.js src/main/resources/ application.properties db/migrations/001_games.sql, 002_clock.sql src/main/java/app/playmancala/ engine/ Board, Pit, PitType, GameStep, MoveHistoryItem, MancalaGame, GameState, GameRule, CommonMancalaGame and the four rules (port of packages/mancala.js) model/ wire types: AuthUser, PlayerInfo, PlayerSummary, UserConnectionInfo, GameUsersConnectionInfo, GameClock, Seat, GameResult, EndReason, GameEndMessage, MoveRecord, GameRecord, FinishedGameSummary, LiveGameSummary, ClientGame, Channels; Json (the shared ObjectMapper) loop/ GameLoop (the game thread), Scheduler (what the logic needs from it), Cancellable auth/ AuthConfig, TokenVerifier, KeycloakTokenVerifier, InvalidTokenException rtmt/ Rtmt (interface used by the game logic), RtmtServer, RtmtSocket, RtmtEndpoint (Quarkus) game/ BackendGame, GameStore, MatchMaker, GameManager, GameClocks, GameClockScheduler, GameSerialization (LiveGameSnapshot), GameRecorder, LiveGames, PublicIds, CrashLog history/ GameHistoryRepository, PostgresGameHistoryRepository, Migrations, GameHistoryService live/ LiveGameStore, ValkeyLiveGameStore, Cache, ValkeyCache http/ GameResource, RootResource, BearerAuth, exception mapper Backend.java wiring and startup (a @Startup bean) ``` The game logic (`engine`, `game`, `history` services, `rtmt.RtmtServer`) is plain Java built with constructors, as the TypeScript classes were, so unit tests build it with fakes. `Backend` creates it from CDI-managed infrastructure (datasource, Redis client, config) and exposes what the HTTP resources and the WebSocket endpoint need. ## Engine A line-by-line port. Classes keep their TypeScript names and methods, so a change to one engine is easy to repeat in the other. The JSON of `MancalaGame` matches `JSON.stringify` of the TypeScript object, because clients rebuild it with `MancalaGame.createFromMancalaGame`: ```json { "id", "board": { "pits": [{ "index", "stoneCount" }], "playerPitCount", "initialStoneCountInPits" }, "player1Id", "player2Id", "turnPlayerId", "state", "gameRules": [{}, {}, {}, {}], "history": [{ "boardSnapshot", "gameSteps": [{ "index", "type", "data" }], "playerId", "moveIndex" }], "currentHistoryItem" } ``` Errors keep their TypeScript messages (`IndexOutOfRange …`, `StoneNotFound …`, `Player cannot move reason : …`); they are `IllegalStateException`s. ## Threads ``` WebSocket event loop ──open/text/close──▶ game loop (1 thread) ──▶ GameManager, LiveGames, MatchMaker, RtmtServer │ ▲ token check (worker) ◀───────────┘ └── timers: clock check 1 s, ping 2 s, auth timeout, preview push HTTP worker ──call(read live state)──▶ game loop, waits for the result; reads saved games itself game loop ──snapshot──▶ store thread ──▶ Valkey (ordered) game loop ──record──▶ recorder thread ──▶ Postgres (retry every 30 s), then cache invalidation ``` `Scheduler` is the part of the game loop the logic uses: `execute`, `schedule(delay)`, `scheduleAtFixedRate` and `now()`. Tests use `ManualScheduler`, which runs tasks at once and timers when the test advances its clock, in place of Jest's fake timers. Every task is wrapped so an exception is logged and never stops a repeating timer. `GameRecorder` and the live game store take an `Executor`; tests pass a direct one, so saves finish before the test looks, in place of `flushPromises`. ## WebSocket `RtmtEndpoint` (`@WebSocket(path = "/")`) handles `@OnOpen`, `@OnTextMessage` and `@OnClose` as `@NonBlocking` and only posts them to the game loop, keeping the order of a socket's events. It wraps the connection as an `RtmtSocket` (`id`, `query`, `send`, `close(code, reason)`, `isOpen`). Sends are asynchronous and never block the loop. `RtmtServer` ports `rtmt_websocket.ts`: per socket an `alive` flag, the user id once authenticated and the auth timeout; per user the set of sockets and the `AuthUser`. Message JSON is parsed on the loop. A token is checked on a worker and the outcome handled on the loop, where a socket closed in the meantime is ignored. ## HTTP Quarkus REST resources, blocking (run on worker threads). Reads of live games go through `GameLoop.call`, and the client game is serialised on the loop because it refers to the live `MancalaGame`. `BearerAuth` checks the `Authorization` header and answers 401. Any other exception becomes 503, as the Express error handler did. CORS and the access log are Quarkus HTTP settings. ## Startup `Backend` (`@Startup`) builds everything, then queues on the game loop, before any WebSocket event can be queued: wait up to 5 s for Valkey, restore live games, start the clock check, the ping and the recorder's retries. Migrations run on their own thread and are retried every 30 s until Postgres is reachable. A restore that takes long (Postgres down: each check waits for the pool's 5 s timeout) delays socket events but loses none. ## Configuration | Variable | Default | Quarkus property | |---|---|---| | `PORT` | `5005` (`6000` in the image) | `quarkus.http.port` | | `KEYCLOAK_ISSUER`, `KEYCLOAK_JWKS_URL`, `KEYCLOAK_CLIENT_IDS`, `LEGACY_USERKEY_AUTH` | as before | `mancala.auth.*` | | `GAME_CLOCK_MS` | `300000` | `mancala.game-clock-ms` | | `VALKEY_URL` | `redis://localhost:6379` | `quarkus.redis.hosts` | | `DATABASE_JDBC_URL` | `jdbc:postgresql://localhost:5433/mancala` | `quarkus.datasource.jdbc.url` | | `DATABASE_USERNAME`, `DATABASE_PASSWORD` | `mancala` | `quarkus.datasource.username`, `.password` | Tests use Quarkus Dev Services with the compose images (`postgres:17.6`, `valkey/valkey:9.1.2`); the unit tests need no Docker. ## Testing - Unit (no Docker): engine and parity fixture, game manager, live games, clock scheduler, token verifier (local key pair and JWKS), `RtmtServer` with fake sockets. - `@QuarkusTest` (Docker): Postgres repository and migrations, Valkey store and cache (including an unreachable Valkey), HTTP API, and the WebSocket endpoint with a real client and a test token verifier. - Manual, in Chrome with `playwright-core` against the local stack (R8.2): two players log in as guests and play a game to the end while a third user spectates and the home page shows the live preview; a player reloads mid-game; the backend is restarted mid-game and the game goes on with the clocks intact; the finished game appears in recent games and replays; a short `GAME_CLOCK_MS` ends a game on time.