Skip to content

Repository files navigation

SudokuPro

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)

Modules

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.

Features

  • 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)

Tech Stack

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)

Quick Start

1. Start a server

Docker Compose (app + Postgres + Redis):

cp .env.example .env    # set real DB_PASSWORD and ADMIN_PASSWORD
docker compose up --build

The 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:run

The 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

2. Play in the browser

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.

3. Start the desktop client

mvn -pl client -am javafx:run

The 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.


Environment Variables

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.


Load Testing

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 50

Database Migrations

Flyway owns the schema; Hibernate only validates it.

  • server/src/main/resources/db/migration/common/ — portable migrations. V1__baseline_schema.sql was generated by Hibernate itself from the JPA entities, so validate passes by construction.
  • server/src/main/resources/db/migration/postgresql/ — vendor-specific migrations (e.g. V2 converts the legacy start_time BIGINT 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).


Scaling

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.


API

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.


Testing

mvn test

204 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.

Beyond the unit suite

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 board

engine/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 ... Engine

AUDIT.md and BUG_AUDIT_2026-07-24.md record what these found, including the reproduction for every fixed bug.


Security Notes

  • Unauthenticated WebSocket connections are rejected at session establishment
  • The board sent to clients is a projection (BoardState) — the solution never leaves the server
  • SecretsGuard fails startup on missing or well-known credentials outside dev/test
  • CSRF protection enabled with CookieCsrfTokenRepository; API clients bootstrap the double-submit token via GET /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)

Per-player isolation

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.

About

Advanced multiplayer Sudoku game with Redis, WebSocket, anti-cheat, analytics, and Docker/Kubernetes support (Spring Boot)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages