A multiplayer Sudoku platform with real-time duels, daily challenges, leaderboards, and an anti-cheat engine. Spring Boot 3.2 backend, JavaFX desktop client, deployable to Docker or Kubernetes (multi-replica ready).
JavaFX client ──REST /api/** + WebSocket /ws/game──▶ Spring Boot server ──▶ PostgreSQL
│
└──▶ Redis (cache, locks, cross-replica pub/sub)
sudokupro/
├── model/ Shared domain: board, cells, moves, generator, constants,
│ and the wire records (model.api). No web, no JavaFX.
├── server/ Spring Boot backend. Produces the deployable boot jar
│ (sudokupro-server-*-exec.jar). Most tests live here.
└── client/ JavaFX desktop app. Owns all JavaFX dependencies and the
per-OS platform profiles. Pure network client: depends only
on model and talks to the server over REST + WebSocket.
client/net (ServerApi, GameSocket, ...) has its own JUnit
coverage — no Spring/Mockito, just the JDK's HttpClient
against a loopback test server.
- Puzzle engine — backtracking generator with a verified unique solution at every difficulty; clue counts are tuned to what single-cell digging can actually reach (a full shuffled sweep tops out near 55-58 removals)
- Real-time multiplayer — raw-WebSocket duels, drip showdowns, and daily challenges; broadcasts fan out across server replicas via Redis pub/sub
- Daily puzzle & streaks — one shared puzzle per UTC day (identical on every replica), per-player copies played through the normal game flow, consecutive-day streaks, and a fastest-solve daily leaderboard
- Head-to-head duels — challenge any player, both race on identical copies of one puzzle, first correct solve wins; ELO ratings, a duel ladder, and one-tap rematches
- Player accounts — self-service registration (
POST /api/auth/register), BCrypt-hashed credentials on the same rows that hold wallets and ratings, password change, and a reserved env-provided admin - Smart difficulty — an adaptive model recommends each player's level: three fast clean solves promote it, three slow/abandoned games demote it; the old DifficultyTuner hook now reports real aggregate skill data
- Weekly tournament — five puzzles per ISO week with ramping difficulty; only players who finish all five are ranked, by cumulative solve time
- Seasons — quarterly duel seasons with lazy exactly-once rollover: the ladder podium earns a SeasonChampion badge and ratings soft-reset toward 1000
- Power-up shop — spend gems on EXTRA_LIFE, REVEAL_CELL, or FREEZE (locks a duel opponent for 10s); inventory lives on the player profile
- Friends & presence — request/accept friendships (the target must be a real account), live online flags from the gameplay channel; spectate a friend's free-play game read-only (the server rejects spectator mutations, and competitive boards — daily, weekly, duel — cannot be watched at all, since every player is racing on the same grid)
- Puzzle sharing — export any board as a compact share code (never the solution); friends import it as their own game
- Achievements & archive — unlocks fire on real play (clean solves, speed, streaks, duel wins); past dailies stay playable from the archive (gems yes, streak credit no)
- Hint economy — hints cost gems, solving earns them (difficulty-scaled, clean-solve bonus); wallets auto-provision with a starting balance
- AI solver & hints — logical move hints with cosmic hotspot ranking; full backtracking auto-solve
- Save & resume — explicit save persists the full grid to Postgres; the desktop client's Load button lists your unfinished games and resumes any of them, surviving server restarts and cache expiry
- Leaderboards — points, cosmic drip, hype meter, duel wins, combined skill score
- Anti-cheat — solve-time, move-rate, complexity, and peer-skill signal scoring with automatic flagging (random flavor mechanics deliberately excluded from enforcement)
- Economy — gems, XP, power-ups, and tier progression (Unranked → Bronze → Silver → Gold → Cosmic)
- Themes — Astral Nebula, Cyber Grid, Manga Mode, Retro Pixel (saved locally per machine)
- Push notifications — optional FCM HTTP-v1 delivery (OAuth2 service account, no Google SDK) with per-player device-token registry, 5-minute cooldown, and automatic dead-token cleanup; disabled unless
FCM_ENABLED=true - Observability — Micrometer metrics and Spring Actuator health (db, Redis, disk, and a game-engine self-test at
/actuator/health)
| Layer | Technology |
|---|---|
| Language | Java 17 |
| Framework | Spring Boot 3.2 |
| Security | Spring Security 6 + OAuth2, fail-fast credential guard |
| Persistence | PostgreSQL + Spring Data JPA, Flyway migrations |
| Cache / Pub-Sub / Locks | Redis (Spring Data Redis + Jedis) |
| Real-time | Raw WebSocket (/ws/game) with cross-replica Redis relay |
| API docs | springdoc-openapi (Swagger UI) |
| Desktop client | JavaFX 21 + JDK java.net.http (REST & WebSocket) |
| Testing | JUnit 5, Mockito, H2, Testcontainers (Postgres + Redis) |
| Build / Deploy | Maven multi-module, Docker, docker-compose, Kubernetes, GitHub Actions (full-suite gate + tagged releases to GHCR) |
Docker Compose (app + Postgres + Redis):
cp .env.example .env # set real DB_PASSWORD and ADMIN_PASSWORD
docker compose up --buildThe app runs with the prod profile: startup fails on missing or well-known
credentials (secret, sudoku123, CHANGE_ME, …) by design. Flyway creates
and migrates the schema automatically.
Or run it locally — prerequisites: Java 17+, Maven 3.9+, PostgreSQL 14+ (Redis 7+ optional; the app degrades gracefully without it):
createdb sudokupro # or: CREATE DATABASE sudokupro;
mvn -pl server -am spring-boot:runThe dev profile is the default for bare local runs and ships working local
defaults (Hibernate ddl-auto=update, Flyway off). Production must set
SPRING_PROFILES_ACTIVE=prod and provide real credentials via the environment.
- API:
http://localhost:8080 - Swagger UI:
http://localhost:8080/swagger-ui.html - Health:
http://localhost:8080/actuator/health
Open http://localhost:8080/play/ — a zero-install web client served by the
server itself. Register or log in and play.
What the page gives you:
| Board | Peer/row/column/box highlighting, same-number highlighting, live duplicate detection, pencil notes |
| Input | Click or full keyboard — 1-9 to place, 0/Backspace to erase, arrow keys to move, N for notes |
| Play | New game at any difficulty, the daily puzzle, hints, undo/redo (Ctrl+Z / Ctrl+Y), save and resume |
| Progress | Gems, level, move count and streak in the HUD; a timer; per-digit "remaining" counts on the number pad |
| Stats | Points leaderboard, today's fastest solvers, and your achievement badges |
The difficulty selector is pre-set from the adaptive model's recommendation for your account. Everything runs over the same REST + WebSocket API the desktop client uses — there is no browser-only code path on the server.
mvn -pl client -am javafx:runThe client is a pure network client — it never loads server code. The welcome
screen prefills http://localhost:8080 / admin (override via
SUDOKUPRO_SERVER, SUDOKUPRO_USER, SUDOKUPRO_PASS); enter the password and
connect. All gameplay flows over REST (/api/**) and the WebSocket channel
(/ws/game). Undo/redo round-trip through the server, which stays
authoritative for every board.
All credentials are injected via environment variables — never hardcoded.
Outside the dev/test profiles, SecretsGuard refuses to start on missing
or well-known values.
Server
| Variable | Default | Description |
|---|---|---|
DB_URL |
jdbc:postgresql://localhost:5432/sudokupro |
JDBC connection URL |
DB_USERNAME |
postgres |
Database user |
DB_PASSWORD |
(required outside dev) | Database password |
REDIS_HOST |
localhost |
Redis hostname |
REDIS_PORT |
6379 |
Redis port |
DDL_AUTO |
validate |
Hibernate DDL strategy — Flyway owns the schema |
ADMIN_USERNAME |
admin |
Default admin account username |
ADMIN_PASSWORD |
(required outside dev) | Default admin account password |
Desktop client
| Variable | Default | Description |
|---|---|---|
SUDOKUPRO_SERVER |
http://localhost:8080 |
Server base URL |
SUDOKUPRO_USER |
admin |
Username (HTTP Basic) |
SUDOKUPRO_PASS |
(empty) | Password — also editable on the welcome screen |
See .env.example for a full template.
loadtest/loadtest.py simulates N concurrent players — each one a fresh
registered account that opens a game (or joins the daily), connects the
gameplay WebSocket, and plays legal moves. Reports latency percentiles:
pip install websockets requests
python loadtest/loadtest.py --base http://localhost:8080 --players 50Flyway owns the schema; Hibernate only validates it.
server/src/main/resources/db/migration/common/— portable migrations.V1__baseline_schema.sqlwas generated by Hibernate itself from the JPA entities, sovalidatepasses by construction.server/src/main/resources/db/migration/postgresql/— vendor-specific migrations (e.g.V2converts the legacystart_timeBIGINT column).- Pre-Flyway databases are baselined automatically
(
baseline-on-migrate=true,baseline-version=1): V1 is skipped, V2+ run.
Both the fresh-install and legacy-upgrade paths are verified against real
PostgreSQL in CI (FlywayMigrationTest).
The Kubernetes deployment supports multiple replicas, provided a shared Redis is available:
- Boards are Redis/DB-backed; each pod keeps only a cache.
- Player streaks, cosmic points, and input locks live in Redis (
PlayerStateStore). - Game mutations take a cross-replica Redis lock (
GameLockManager). - WebSocket broadcasts fan out to all pods via Redis pub/sub (
RedisBroadcastRelay), so players in the same game see each other regardless of which pod they hit.
Without Redis, every component degrades to single-replica behavior (logged once). Cross-pod delivery is verified by a two-pod integration test on real Redis in CI.
| Method | Path | Description |
|---|---|---|
POST |
/api/game/new?difficulty=1..4&chaos=&mirror= |
Create a new game for the authenticated player |
GET |
/api/game/{gameId} |
Current board state (player-visible projection — never the solution) |
POST |
/api/game/{gameId}/solve |
AI auto-solve (owner only) |
POST |
/api/game/{gameId}/end |
End/leave a game (state persisted server-side) |
POST |
/api/game/{gameId}/save |
Explicitly save a game (full grid persisted to Postgres) |
GET |
/api/game/saved?limit= |
The caller's unfinished, resumable games, newest first |
POST |
/api/game/{gameId}/resume |
Resume a saved game (survives restarts and cache expiry) |
GET |
/api/game/hint |
AI hint for the caller's active game (charged to the caller) |
GET |
/api/daily |
Caller's daily-puzzle status: joined, completed, streak |
POST |
/api/daily/join |
Join today's shared puzzle (idempotent, returns the caller's copy) |
GET |
/api/daily/leaderboard?limit= |
Today's fastest solvers |
POST |
/api/duel/challenge |
Challenge a player ({opponent, difficulty}) |
POST |
/api/duel/{id}/accept |
Accept a duel — returns your board, race starts |
POST |
/api/duel/{id}/decline |
Decline a pending duel |
GET |
/api/duel |
The caller's duels (pending, active, finished) |
POST |
/api/duel/{id}/rematch |
Rematch a finished duel (fresh challenge, same difficulty) |
GET |
/api/duel/leaderboard?limit= |
Duel ladder by ELO rating |
GET |
/api/daily/archive?limit= |
Dates with playable archived dailies |
POST |
/api/daily/archive/{date}/join |
Play a past daily — strictly past dates (no streak credit) |
POST |
/api/auth/register |
Create a player account (no auth required) |
POST |
/api/auth/password |
Change the caller's password |
GET |
/api/game/recommended-difficulty |
The adaptive model's difficulty for the caller |
GET |
/api/game/{id}/share |
Share code for a puzzle (grid only, never the solution) |
POST |
/api/game/import |
Import a shared puzzle as your own game |
GET |
/api/friends |
Friends with online flags |
GET |
/api/friends/pending |
Incoming friend requests |
POST |
/api/friends/request/{name} |
Send a friend request |
POST |
/api/friends/accept/{name} |
Accept a request (mutual friendship) |
DELETE |
/api/friends/{name} |
Remove a friend |
GET |
/api/tournament |
This week's tournament progress |
POST |
/api/tournament/{1-5}/join |
Join one of the week's five puzzles |
GET |
/api/tournament/standings |
Weekly standings (full finishers only) |
GET |
/api/season |
Current season + end date (triggers due rollover) |
GET |
/api/powerups |
Power-up catalog and your inventory |
POST |
/api/powerups/buy/{type} |
Buy a power-up with gems |
POST |
/api/powerups/use/{type} |
Use one (gameId or target param) |
GET |
/api/economy/wallet |
Gems, XP, level, duel record + rating, hint price |
GET |
/api/economy/achievements |
The caller's achievements |
POST |
/api/notifications/device-token |
Register the caller's FCM device token for push notifications |
DELETE |
/api/notifications/device-token |
Remove the caller's device token (opt out of push) |
GET |
/api/session |
Auth check + CSRF bootstrap for API clients |
GET |
/api/leaderboard?limit= |
Public leaderboard |
GET |
/api/events |
Active live events |
WS |
/ws/game |
Gameplay channel (authenticated principal required) |
GET |
/admin/constants |
Game constants (admin) |
POST |
/admin/constants/reload |
Hot-reload constants (admin) |
GET |
/actuator/health |
Health: db, Redis, disk, game-engine self-test |
WebSocket envelope format: {"type", "from", "payload"} — client-to-server
types: move, join, chat, undo, redo, sync; server-to-client adds
board (full-state resync), leave, event, status, hint, error.
Join an existing game by connecting to /ws/game?gameId=....
Full interactive docs at /swagger-ui.html when running locally.
mvn test204 tests. Unit and context tests run anywhere (H2-backed, no local services needed). Four integration tests are Docker-gated and skip automatically without Docker: Flyway migrations on real PostgreSQL and cross-replica broadcast on real Redis. CI (GitHub Actions) runs the full suite including the Docker-gated tests, then builds the server Docker image.
SecurityRulesTest deliberately runs under the dev profile rather than test,
because SecurityConfig is annotated @Profile("!test") — under the test profile
it is switched off and Boot's default chain runs instead, so nothing would be
verifying the real permitAll matchers, CSRF rules, or admin role.
Some classes of bug only appear with a real Redis, a real servlet chain, or a real
browser. Three harnesses cover those; all need a running server (see
testing/README.md for the exact command).
python3 testing/adversarial_api_test.py # 27 black-box authz / validation checks
python3 testing/fuzz_edge_cases.py # 28 hostile-input checks (HTTP + WebSocket)
python3 testing/reward_replay_probe.py # replays /end on a solved boardengine/Engine.java is a game-playing harness that drives the real model classes —
it generates boards, solves them with an independent backtracker, plays the winning
moves, and asserts 20 invariants across generation, undo/redo, snapshots, mirror
mode, concurrency (8 threads × 400 ops) and property fuzzing:
javac -cp model/target/classes:<jackson+slf4j jars> engine/Engine.java && java ... EngineAUDIT.md and BUG_AUDIT_2026-07-24.md record what these found, including the
reproduction for every fixed bug.
- Unauthenticated WebSocket connections are rejected at session establishment
- The board sent to clients is a projection (
BoardState) — the solution never leaves the server SecretsGuardfails startup on missing or well-known credentials outside dev/test- CSRF protection enabled with
CookieCsrfTokenRepository; API clients bootstrap the double-submit token viaGET /api/session(WebSocket endpoints exempted) - Security headers: CSP, HSTS (1 year),
X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy - WebSocket origins restricted via
sudokupro.ws.allowed-origins(defaults to localhost) - HTTP Basic login attempts are rate-limited per remote address (
LoginAttemptLimiter+LoginAttemptFilter): 5 failures within 60s locks out further attempts with a 429 (sudokupro.security.login.max-attempts/.lockout-seconds). Redis-backed so the lockout holds across replicas; degrades to a local in-memory counter if Redis is down. - All credentials sourced from environment variables — no secrets in source
- The Kubernetes deployment runs as the same non-root user as the container image
(
securityContext:runAsNonRoot,readOnlyRootFilesystem, all capabilities dropped)
Game ids are deterministic and usernames are public from the leaderboards, so every endpoint that touches a specific board checks the caller:
| Action | Rule |
|---|---|
POST /{id}/solve, /save, /resume, /end |
Owner only (403 otherwise) |
GET /api/game/hint |
Owner only; the charge lands on the caller, never the board owner |
GET /api/game/{id} |
Readable — spectating a free-play game is a feature |
WebSocket move / undo / redo |
Owner only |
| WebSocket connect to a daily, weekly or duel game | Owner only — these are per-player copies of one shared grid, so another player's copy is an answer key |
POST /api/daily/archive/{date}/join |
Strictly past dates — an archive copy of today would be a solution oracle |
Completion payouts are idempotent (rewards_granted, Flyway V7): a solved board pays
gems, XP, achievements, streaks and duel results exactly once no matter how often /end
is replayed.
The server always stamps a move's MoveSource itself and ignores any client-supplied
value, so a client cannot label its own move HINT or AUTOSOLVE — both of which the
reward guards key on.
WebSocket sessions are rate-limited with a token bucket (40 burst, 20/s sustained), chat is truncated at 500 characters, and frame limits are set per session; without the limit an unthrottled client could hold the cross-replica game lock in a loop and starve the board's real owner.
See AUDIT.md for the original code-health audit, and BUG_AUDIT_2026-07-24.md for the
current audit — every finding there is recorded with its reproduction and its fix.