The backend README, how to run and test it, what is easy to get wrong with the game loop and the shared data formats, and the deployment note. The web app was played against it in Chrome: a full game with a spectator, a reload, a restart mid-game, the replay and a game lost on time.
11 KiB
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/ TokenVerifier, KeycloakTokenVerifier (made from config by TokenVerifierProducer), 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 (every route), BearerAuth, ErrorMapper (503 for failures)
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:
{ "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 IllegalStateExceptions.
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),
RtmtServerwith 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-coreagainst 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 shortGAME_CLOCK_MSends a game on time.