Quorix — Program Structure
Onboarding doc for the codebase: what each package does and how the pieces connect.
(This file covers the Java client/server codebase only. The public website
(website/) is a separate Python/Flask project with its own website/README.md.)
QuorixClient/ and QuorixServer/ are no longer byte-identical — QuorixServer/
now only carries the ~100 files ServerMain actually needs (found by compiling just
that entry point against the full tree and keeping whatever the compiler pulled in,
not guesswork): net, account, social, admin, economy, games (rule
engines/match managers, no Window/Dialog classes), and ai.knowledge only. The entire
ui, theme, and pages packages, every game's Window/Dialog class, and the whole
ai.search/ai.grid/ai.steering practice-mode bot engine exist only in
QuorixClient/. Everything below describes QuorixClient/ (the fuller codebase);
where a package/class also exists in QuorixServer/, it's noted inline. A handful of
protocol/data classes are marked (shared) below: they carry a "SHARED (Common)"
javadoc tag and are the literal same file copied into both trees (net/Message.java,
net/MessageType.java, net/NetworkConfig.java, games/GameInfo.java,
games/FileHash.java, account/Account.java, economy/ShopItemInfo.java,
economy/ChallengeProgressInfo.java).
That (shared) tag list is not the full picture, and has already caused one real
bug (a practice-reward fix that only landed in QuorixServer/ and silently missed
QuorixClient/ - found and fixed 2026-09-25). The actual rule: every file under
net/account/social/admin/economy/games/ops (the same packages ServerMain
needs, minus each game's Window/Dialog classes) needs to stay byte-identical between
the two trees, not just the handful with the javadoc tag - because
QuorixClient/ServerMain.java is the edit-source-of-truth copy of the same class
QuorixServer.jar's manifest actually runs, and (until 2026-09-25) a
now-removed in-app "Host a Server" feature also ran this exact server logic live
from inside the ordinary client. Any server-side bug fix or behavior change in
those packages must be copied to the same path under QuorixClient/ too, checked
with a diff, not just assumed. ClientHandler.java is the biggest example that
isn't in the (shared) tag list despite needing to be.
The client itself can no longer host a server (removed 2026-09-25). The
in-app "Start Hosting" button (SettingsPanel) and HostServerDialog.java are
gone - hosting is exclusively a QuorixServer.jar thing now, never something a
player can switch on from the regular client. QuorixClient/ still carries the
full server-side packages (net/account/social/admin/economy/games)
because it remains the edit-source-of-truth copy that gets synced into
QuorixServer/, and because QuorixClient/ServerMain.java exists as that same
copy of the dedicated-server entry point - neither of those is a player-facing
hosting feature.
Added 2026-10-08: ops/BackupTask (daily zip of the gamehub_* data files, keeps 3), chat/ChatFilter (masks blocked words; ROT13-encoded list; blocked-words.txt), social/BlockManager (blocks and the direct-message policy, gamehub_blocks.txt; FriendManager.removeFriend ends a friendship), ClientHandler.filterWrittenText (the single gate that cleans player-written text) and client-only ui/PlayerActions (confirm + send for remove friend / block / unblock).
ops/ (added 2026-10-07) - the unattended-server machinery, in both trees and byte-identical like the
packages above (so ServerMain.java, which uses it, can be too): SelfUpdater (every 48 h fetches the latest GitHub
Release, verifies SHA256SUMS, installs after 20 idle minutes, exits for systemd to restart), NoticeWatcher (notice.txt
-> net.ServerNotice, plus temporary updating/back notices), StatusFile (writes public/status.json), WebApiServer + PlaytimeTracker + DominionView (loopback-only HTTP API for the website, off unless QUORIX_WEB_API_KEY is set). Supporting shared
classes in net/: ServerNotice (the one banner + its SERVER_NOTICE push), ServerActivity (when anybody last did
something). Client-only: net/ServerStatusFetcher (reads http://host/status.json when the server can't be reached) and
ui/ServerNoticeBanner (the strip above the top bar and on the sign-in window).
Entry points
Quorix.java— client entry point (QuorixClient/only). Showsui/SplashScreen, thenaccount/AuthWindow(login), installs a last-resort uncaught-exception handler (plainJOptionPane, so it works even if theming itself is broken), and kicks offnet/ClientUpdateCheckerin the background.ServerMain.java— server entry point, in both trees but not identical between them (see above). Startsnet/GameServer(the real multiplayer server socket) and blocks the main thread forever (GameServer's accept loop is a daemon thread, so something non-daemon has to keep the JVM alive) — headless, no GUI, no window, logs to the console. Earlier versions also opened anAuthWindow/client UI in the same process ("a server is also a playable client pointed at itself"); that's gone now thatQuorixServer/is meant to run unattended on a real machine, where opening a Swing window would throwHeadlessException. The old combined behavior is still available by runningQuorixServer.jarandQuorixClient.jarside by side.
Architecture at a glance
net (protocol + dispatch)
-> games (plugin-style match framework + ~30 online-game triples + ~21 offline games)
-> economy / social / ai (services the match layer calls into - ai.knowledge
only for the server; the rest is client-only)
-> pages / ui / theme (Swing client shell - QuorixClient/ only)
account / admin (identity + role gating, cross-cutting client & server)
A client message becomes a running game like this: ClientHandler reads a Message off
the socket → dispatches by MessageType (e.g. FIND_MATCH_REQUEST) → the matching
<Name>MatchManager.findMatch(this) pairs the player and constructs a <Name>Match →
ClientHandler.setCurrentXxxMatch(match) stores it on the connection → subsequent
MAKE_MOVE_REQUESTs route straight to currentMatch.handleMove(...).
net — networking and the central message dispatcher
GameServer.java— the composition root. Constructs every manager (accounts, economy, leaderboard, achievements, chat, party, friends, moderation, feedback, replay, tournaments, avatars, admin log, and one<Name>MatchManagerper online game — ~30 of them), wires cross-manager dependencies (e.g.leaderboardManager.setAchievementManager(...)), builds theai.knowledgetrivia fact-lookup stack and threads it intoTriviaMatchManager, then runs the accept loop: each newSocketbecomes aClientHandleron its ownThread, given references to every manager.ClientHandler.java(~2900 lines) — one instance per connected client. ReadsMessages in a loop and dispatches through a largeMessageType→ privatehandleXxxchain (100+ request types). Holds per-connection state: logged-in account, and onecurrent<Game>Matchfield per online game. On disconnect, cancels waiting/active matches on every manager so nothing hangs server-side. Also handles login/account creation (signing up only ever creates aRole.PLAYER- the first admin comes fromaccount/AdminBootstrapat the server console), Website sign-in reuses this store throughops/WebApiServer(loopback API);account/AccountRulesholds the shared sign-up checks. moderation and admin actions (role re-checked here, server-side, for every privileged request), avatars, feedback, and client auto-update.Message.java/MessageType.java(shared) — the wire protocol: one multi-fieldMessageclass carrying whichever optional fields a givenMessageTypeneeds, rather than one class per message type.QuorixSerializationFilter.java(shared) — ajava.io.ObjectInputFilterallow-list applied to everyObjectInputStreamthat reads aMessageoff a raw socket (ClientHandlerandNetworkManagerboth callin.setObjectInputFilter(QuorixSerializationFilter.FILTER)right after constructing their stream). Closes the classic unfiltered-readObject()Java deserialization RCE class of bug - without it, either side would deserialize whatever class graph the other end sends, no matter what. Adding a new custom-type field toMessage.java(or to one of the types it already carries) means adding it to the filter's allow-list too, or that field silently stops deserializing.NetworkManager.java— client-side counterpart, one persistent socket, three modes: blocking request/responsesend(); fire-and-forgetsendAsync()+PushListenerfor server-initiated pushes; offline queueing forsendAsyncwhile disconnected, flushed on reconnect. The push-listener queue has no per-request correlation, so a few UI panels deliberately use blockingsend()on a background thread instead ofsendAsync+ listen, to avoid one call stealing another's reply.sendAsync()always returnstrue(queues rather than fails) - callers that need to tell the player the server isn't reachable should calldescribeIfNotReady()instead of checkingsendAsync's return value; it returns a player-facing string for OFFLINE/CONNECTING/RECONNECTING andnullwhen ONLINE. All 25 find-match screens across the games package use this pattern.NetworkConfig.java(shared) — shared mutable host/port config, set at runtime.UpdateManifest.java(net, shared) — where the app learns the newest client without the game server: the website'slatest.json, then GitHub's latest release; "newer" = different file with a laterImplementation-Build-Date;stage()downloads over https, checks SHA-256/size/main class and writes<name>.new. Used byClientUpdateCheckerand the launcher'sPreStartUpdate, which fall back to the game server only if neither web source answers.ClientUpdateChecker.java/ClientUpdatePackage.java— auto-update: client hashes its running jar (games/FileHash) and asks the server if it's stale; server re-readsQuorix.jar's hash whenever its mtime changes. Client stagesQuorix.jar.new; an external native launcher swaps it in on next start. Before staging, the client re-hashes the download againstMessage.newJarHash(the server's own jar hash, sent alongside the version-check response) and refuses to stage it on a mismatch - an integrity check against a corrupted transfer only, not an authenticity one. No code signing and no TLS yet - the single most severe security gap flagged in the platform strategy analysis, tracked as an open question inBLOCKED_QUESTIONS.mdrather than guessed at.ConnectionState.java/ConnectionIndicator.java— connection-status enum- colored-dot widget.
NavigationListener.java— callback interface lettingpages/Sidebarreport nav clicks without knowing how paging works.
games — the match framework, plus ~30 online-game triples and ~21 offline games
Two repeating shapes, not 179 individual designs:
- Offline/single-player (Snake, Block Stack, 2048, Number Nest, Mine Grid, Sudoku,
Simon Says, Whack-a-Mole, Match Three, Maze Chase, Brick Breaker, Flap Flyer,
Galaxy Defender, Word Guess, Bubble Shooter, Lights Out, Peg Solitaire, Klondike,
Five Dice, Mancala, Puzzle Quest, Dino Dash, Crossing Road, Aim Trainer, Pong, and
more): a
<Name>Game.java(pure state/logic) +<Name>Window.java(Swing host), sometimes a<Name>Panel.java. No server involvement. Number Nest (added 2026-09-27) is the games-backlog'sThrees/1010!-genre concept - a 5x5 grid, place one offered piece at a time into any empty cell (nothing slides, unlike 2048), merging with at most one orthogonally-adjacent equal-value neighbor per placement (fixed scan order, deliberately no cascade); game over exactly when the grid is full, since placement never needs adjacency the way 2048's slide does.NumberNestGame/NumberNestWindowfollowMerge2048Game/Merge2048Window's exact split and share its tile-color formula for visual consistency between the two number-merge games. Sky Hopper (added 2026-09-29) is the games backlog's vertical-climber concept (Doodle Jumpgenre) - bounce automatically off procedurally generated platforms (normal/moving/breakable/spring, weighted 55/20/15/10%) as a camera that only ever scrolls up follows the player past a fixed screen fraction; falling below the bottom of the current view ends the run. World coordinates use a screen-like y-down convention throughout (climbing decreases y) so the physics and the renderer share the same numbers with no unit conversion, unlikeHillClimbGame's separatePIXELS_PER_UNITscale.SkyHopperGame/SkyHopperWindowfollow the same<Name>Game/<Name>Windowsplit andengine.GameLoopusageHillClimbGame/HillClimbWindowestablished. - Online/multiplayer (~30: Tic-Tac-Toe, Connect Four, Checkers, Chess, Battleship,
Reversi, Dots and Boxes, Rock Paper Scissors, Memory Match, Air Hockey, Word Duel,
Dice Duel, Snake Arena, Block Stack Duel, Fusion Grid, Typing Duel, Signal Grid, Card
Rush, Square Wars, Racing, Impostor Ship, Zombie Survival, Fight Arena, Space Battle,
Trivia Blitz): a triple —
<Name>Match.java(server-authoritative rules/state),<Name>MatchManager.java(matchmaking queue, constructs the Match, wired with whichever ofEconomyManager/GameHistoryManager/ChatManager/LeaderboardManager/AchievementManager/ReplayManager/PartyManagerit needs),<Name>Window.java(client UI). Four sub-patterns for how a Match actually runs, by game: - Turn-locked/state-broadcast (Chess, Battleship, Connect Four, Checkers, Reversi, ...): validate → apply → broadcast new state.
- Shared-seed independent simulation (Racing, Zombie Survival, Space Battle): every player gets the same RNG seed, plays an identical procedurally-generated run locally with no position sync, and reports only a final outcome — a deliberate scalability tradeoff over broadcasting positions 60×/sec.
- Real continuous tick loop (Fight Arena's
FightMatch) — the one game with a genuine ~15/sec server tick thread and full-authoritative snapshot broadcast. - Shared live grid, discrete claims (
SquareWarsMatch) — not turn-locked; any player can claim any cell any time, server validates and broadcasts per claim.
Framework/shared classes worth knowing (read these instead of the ~30 game triples):
Game.java— the interface offline games notionally implement (getInfo(),start(),pause(),saveState()/loadState()).GameInfo.java(shared) — one catalog entry: id, name, category, mode label, online/comingSoon flags, version.GameRegistry.java— the hardcoded master catalog (49GameInfoentries, byte-identical betweenQuorixClientandQuorixServer- never references Swing, since the server compiles this file too with no UI classes available to it).ClientHandler.handleGameList()serves this, patching in live queue counts for a few games. One capability flag so far:spectatable(true only for Chess, Rock Paper Scissors, Battleship - the three games with a realSpectateDialog"Watch" entry point), set via a smallmarkSpectatable(...)helper called after the list is built rather than a required constructor parameter every other call site would have to pass.type("Single Player"/"Multiplayer"/"Single/Multiplayer") already mostly covers offline-capability, so a separate redundant flag for that wasn't added. Further capability flags (min/max players, party-joinable) are deliberately not built ahead of a concrete feature that needs them.GameManager.java— client-side cache of the fetched catalog.GameWindowFactory.java— client-only (unlikeGameRegistry/GameInfo, since every entry here references a*Windowconstructor that only exists inQuorixClient): aMap<String, Supplier<JComponent>>from game id to "how to build its embedded window." Replaces what used to be a 49-branchif/elseinGameLauncher.openGame- by the end of the embedded-games rollout, every single branch had the exact same shape (MainMenu.getInstance().showGame(new XxxWindow())), so it collapsed mechanically into one map. Verified with a smoke test constructing all 49 windows through the factory and confirming the id set matches exactly, nothing throws, and an unknown id returnsnull.GameWindowKernel.java— two static helpers for the Swing boilerplate every embedded game window has hand-rolled at least once (many several times) since the embedded-games conversion:centered(JComponent)wraps content in a non-opaqueGridBagLayoutpanel so it renders at its own preferred size instead of stretching to fill aCardLayout/BorderLayout.CENTERslot (the exact bug this session hit and fixed, in a slightly different disguise, on nearly every one of the 49 game conversions);center(Container, JComponent)is the same fix for the other common shape, where a screen's own outer panel should do the centering itself rather than being wrapped by a new one.leaveButton(Runnable)builds the standard themed "Leave" button, with the actual leave action left to the caller (most callMainMenu.getInstance().returnToGames();SnakeWindowroutes through its ownsetReturnActioncallback instead). Deliberately a static helper, not a base class every window must extend - the existing ~51 windows have too much individual shape to retrofit onto one shared superclass safely in one pass; adopted opportunistically so far byReversiWindow/BrickBreakerWindowas the proof it generalizes (one online-multiplayer game, one offline game), not yet rolled out further.GameLauncher.java— the plugin launch dispatch, and the mandatory rules-page gate every "Play" entry point (games page, quick-play dropdown, global search, hero banner, game invites) already shares:launch(Component, GameInfo), the only public method, shows the in-app game-detail page (GameDetailPanelviaMainMenu.showGameDetails(...): art, tags, difficulty, rules, a real Play button) instead of opening the game directly - there's no way to skip straight to playing. Package-privateopenGame(...)(callable only fromGameDetailPanel's own Play button, same package, once someone has actually seen that page) is now a singleGameWindowFactory.factoryFor(id)lookup instead of a branch per game:MainMenu.getInstance().showGame(factory.get())when a factory exists, otherwise the same "not converted yet" noticelaunch(...)already shows forcomingSoonids. All 53 games in the catalog go through this path - every offline/single-player game (including Hill Climb, the first new game added after the embedded-games rollout - see the "Done" section ofROADMAP.mdfor what it demonstrates), both games withSpectateDialog/tournament support (Chess, Rock Paper Scissors, Battleship), and every online-multiplayer game (including Telephone,games/TelephoneWindow.java- a Gartic-Phone-style draw/guess chain, seeROADMAP.md's "Done" section). A game reachable from more than one place (those same threeSpectateDialog"Watch" entry points, separate fromGameLauncher) needs every one of those call sites updated, not just the main one - a real bug (Chess's spectate path silently doing nothing onceChessWindowbecame aJPanel) shipped from missing this the first time and had to be found and fixed separately; Rock Paper Scissors's and Battleship's own spectate lines were fixed proactively in the same conversion instead. Snake is also reachable pre-login frompages/OfflineHubWindow.java("Play Offline" on the login screen), which has noMainMenuto hand off to - seeSnakeWindow.setReturnAction(...)below.EmbeddedGamePanel.java- implemented by a game panel embedded inMainMenu's game-host slot rather than opened as its own window (ChessWindowis the first). One method,requestLeave():MainMenucalls it before navigating away from a hosted game (e.g. a Sidebar click mid-match) - the embedded replacement for the old per-windowwindowClosingconfirmation, since there's no window-close event once a game isn't its own window. Returnfalseto intercept (show a confirm dialog, then callMainMenu.getInstance().returnToGames()yourself if confirmed) rather than letting navigation proceed silently mid-match. A conversion gotcha found while converting Reversi (the second game done this way, after Chess): a board that fills its space automatically because it's laid out with a real layout manager (Chess's is a plainGridLayout(8,8)of cell components) needs no extra work, but a board that's one custom-paintedJPaneldrawing itself at a hardcoded pixel size (Reversi's is) keeps that same size once embedded and just sits pinned in a corner of the much bigger game-host slot with dead space around it - fixed the same way an over-small mode-select screen is fixed: wrap it in aGridBagLayoutpanel with no constraints (auto-centers) rather than trying to make the paint code itself size-aware, which would also risk breaking its pixel-based mouse-click math. Tic-Tac-Toe (fifth game converted) turned up a variant of the same bug: its board was already wrapped in a centering container, but that container usedFlowLayout, which only centers its children horizontally - vertically it always top-aligns - so the board stayed pinned to the top with dead space below once embedded. SameGridBagLayoutfix applies; the lesson generalizes to any pre-existing "centering" wrapper, not just ones that are missing entirely. Snake (eighth game converted, and the first offline/single- player one) turned up a different kind of gotcha: it's also reachable pre-login frompages/OfflineHubWindow.java's "Play Offline" screen, which is its own standaloneJFramewith noMainMenuto hand off to - hardcodingMainMenu.getInstance().returnToGames()the way every other converted game does wouldNullPointerExceptionfor a logged-out guest.SnakeWindow.setReturnAction(Runnable)fixes this: unset, it falls back to the usualMainMenu.getInstance().returnToGames();OfflineHubWindowsupplies its own smallCardLayout"go back to the hub" callback instead. Any future offline-capable game added toOfflineHubWindowneeds the same treatment.GameMetadata.java— presentation-only (difficulty, tags) for game detail dialogs; has no effect on matchmaking or gameplay.MatchManager.java— the original reference matchmaking manager (fortictactoe-online): FIFO pairing queue, constructs the Match, broadcasts queue-count updates. Every other<Name>MatchManagerstill follows this same shape with per-game tuning, though it's now also available asMatchmakingKernel(below) for a new adopter to build on top of instead of hand-rolling it fresh -MatchManageritself stays hand-rolled since retrofitting the reconnection-registry-owning original isn't worth the churn for no behavior change. ItsgetReconnectRegistry()returns the server-widemechanics.ReconnectRegistry.shared()(since 2026-09-30), passed into eachTicTacToeMatch.MatchmakingKernel.java— the FIFO waiting-queue shape every<Name>MatchManagerhand-rolled, extracted generic over the match type via a smallPairHandlerinterface (pair(matchId, a, b)constructs+starts the match;attach(handler, match)wires it to that handler's owncurrentXxxMatch-style field). The kernel itself never constructs, starts, or inspects a match - it only tracks the waiting list and thematchId -> matchmap, delegating everything match-type-specific to thePairHandler. Takes an optionalmatchIdPrefixseparate fromgameId(defaults togameIdif the 4-arg constructor is used) - found necessary retrofitting Connect Four, whose matches are"connect4-N"while itsGAME_ID(used forQUEUE_UPDATE/ history tracking) is"connect-four"; preserved exactly rather than silently changed, even though the client only ever comparesmatchIdfor equality and never parses it. ItsgetReconnectRegistry()returns the sharedmechanics.ReconnectRegistry(was an instance of its own before 2026-09-30)- every kernel-backed match type gets grace-period reconnect support for free the
moment it adopts the kernel, added 2026-09-26 alongside the reconnect rollout below.
Adopters:
CheckersMatchManagerandConnectFourMatchManager(the original two, proving the shape generalizes and thematchIdPrefixdivergence case),Reversi- MatchManagerandDotsAndBoxesMatchManager(converted from their own hand-rolled queues while adding reconnect, since they needed a registry anyway), andDiceDuelMatchManager/TypingDuelMatchManager(2026-09-29 - plain 2-player FIFO managers with no rematch/spectate/tournament glue, textbook conversions; hit the samematchIdPrefixdivergence Connect Four did - each game's real matchId format has no hyphen while itsGAME_IDdoes, and a first-pass conversion using the kernel's short constructor would have silently changed it server-wide, caught byQuorixServerTests/games/MatchmakingKernelAdoptersTest.javabefore landing). Neither Dice Duel's nor Typing Duel'sMatchclass takes aReconnectRegistry(neither game has adopted reconnect), so unlikeCheckersMatchManagerthese two don't exposekernel.getReconnectRegistry(). AlsoAirHockeyMatchManager/MemoryMatchMatchManager(2026-09-29 - same textbook shape, samematchIdPrefixdivergence pattern: real matchIds "airhockey-"/"memory-" vs.GAME_IDs "air-hockey"/"memory-match"; this round applied the lesson proactively and hardcoded the correct prefixes from the start rather than hitting the bug again). Neither'sMatchclass takes aReconnectRegistryeither, so neither exposeskernel.getReconnectRegistry(). AlsoSignalGridMatchManager/FusionGridMatchManager(2026-09-29, same day's work) - identical shape and the samematchIdPrefixdivergence ("signalgrid-"/ "fusiongrid-" vs.GAME_IDs "signal-grid"/"fusion-grid"), prefixes hardcoded correctly from the start; neither'sMatchclass takes aReconnectRegistryeither. AlsoCardRushMatchManager/SnakeArenaMatchManager/TetrisDuelMatchManager(2026-09-29, same day's work) - identical shape and the samematchIdPrefixdivergence ("cardrush-"/"snakearena-"/"tetrisduel-" vs.GAME_IDs "card-rush"/"snake-arena"/"tetris-duel"), prefixes hardcoded correctly from the start; none of the threeMatchclasses take aReconnectRegistryeither.RacingMatchManager/SpaceBattleMatchManager(3-6 player group races)/SquareWarsMatchManager(2-4 player groups) andChessMatchManager(spectator support the kernel doesn't have) were checked and screened out as not drop-in fits for this kernel as it exists today. A wider rollout to the remaining ~13<Name>MatchManagerclasses remains optional cleanup, adopted a couple at a time whenever convenient, not a requirement (seeROADMAP.mdfor good next candidates and which games are deliberately NOT drop-in fits as the kernel exists today). ELO deliberately isn't part of this -LeaderboardManager's rating math is a separate, already-shared concern untouched by matchmaking queue mechanics. ReconnectRegistry.java— moved tomechanics/on 2026-09-30, and the adopter history below is now partly out of date: every game listed as "not adopting" further up (Dice Duel, Typing Duel, Air Hockey, Memory Match, Signal Grid, Fusion Grid, Card Rush, Snake Arena, Block Stack Duel) and Chess have since adopted it - see themechanicssection for the current state and the 30-second window (the 45s mentioned below is historical). Generic disconnect-grace-period mechanism, keyed by accountId (a brand-newClientHandler/socket exists on reconnect, so accountId, not the handler reference, is the only stable identity). Any match class can adopt it by implementing the smallReconnectableMatchinterface (onReconnectTimeout(),onReconnect(newHandler),attachToHandler(handler)) and callingbeginGracePeriod(accountId, this)from its own disconnect handling. Adopters:TicTacToeMatch(the original, viaMatchManager's own registry instance),ConnectFourMatch/CheckersMatch/ReversiMatch/DotsAndBoxesMatch(2026-09-26, each via its manager'sMatchmakingKernel-supplied registry), andWordDuelMatch(2026-09-27, via its own direct registry field onWordDuelMatchManager- proof that adopting this doesn't requireMatchmakingKernelmigration at all, the same stand-alone shapeMatchManageralways used). The samedisconnectedSlot/ grace-period/timeout shape in every one, differing only in how each match names its two player slots (DotsAndBoxesMatchuses aList<ClientHandler>and an index rather than two named fields;WordDuelMatchuses a nullableBooleanfor the same reasonTicTacToeMatchuses a char - two named fields, not a list). (Chess was not adopted at the time - its resign/draw-offer state interacted with a mid-grace-period reconnect in ways not yet designed, tracked inROADMAP.mdrather than guessed at.)ClientHandler.handleLogin()tries every reconnect-aware match type's own registry in turn (tryReconnectAllGames()) and, if one had a match waiting, populates theLOGIN_RESPONSEwith everything the client needs to resume (reconnectGameId/reconnectTurnSymbolplus the existingmatchId/symbol/opponentUsername/boardStatefields a match-found push already carries) - the client (pages/MatchResume.resumeIfPending- wasAuthWindow.resumeMatchIfPendinguntil 2026-09-30 - via the small per-gamereconnectMessageTypesFor()lookup covering all 8 adopters' own message-type pairs) reconstructs the equivalent of a fresh match-found + update locally from those fields and feeds them straight to a newly-built game window, deliberately not via a second server push to the reconnecting client's own socket (that race is explained inROADMAP.md). A guest (no account) disconnect always forfeits immediately - no stable identity to grant a grace period against. Threading note load-bearing for anyone extending this to another game:ReconnectRegistry's own lock must only ever be acquired either alone, or immediately before calling into the match (never the reverse) - see the class's own javadoc for the full reasoning. A real limit found adding the 6th adopter (Word Duel, 2026-09-27):resumeMatchIfPending()'s generic reconstruction only ever setssymbol/boardStateon the synthetic push messages - correct for the first 5 adopters purely because they're all flat-board grid games whose real protocol happens to fit those exact two fields, not because the mechanism is actually generic. Word Duel's real protocol usestriviaQuestion/triviaScoresinstead, soWordDuelMatch. onReconnect()repurposesReconnectResult'sboardStateslot to carry the match's letters and itsturnSymbolslot to carry the reconnecting player's own "mine:opponent" length tuple (see that method's javadoc), andresumeMatchIfPending()gained one smallif ("word-duel".equals(gameId))branch to unpack them back into the fieldsWordDuelWindowactually reads. Battleship now joins the reconnect-aware games too (BattleshipMatch/BattleshipMatchManagergained the sameReconnectRegistry/grace-period shape;fire()now also rejects a shot attempt server-side while the opponent is mid-grace-period, matchingTicTacToeMatch's freeze check) - its real MATCH_FOUND already uses plain symbol/boardState, soresumeMatchIfPending()only needed a "skip the generic second UPDATE message" branch rather than a repurposing one (Battleship has no generic*_UPDATEtype, only the richly-shapedBATTLESHIP_FIRE_RESULT); the still-connected opponent's "waiting to reconnect" UI is cleared via that same message type sent with acellIndexof -1 as a sentinel (BattleshipWindowrecognizes it as a resync, not a real shot). Honestly- flagged gap:ReconnectRegistry.ReconnectResulthas no slot for "every past shot," so the reconnecting player's own two grids repaint from a fresh fleet-layout MATCH_FOUND rather than replaying their hit-marker history - cosmetic only, server-side state (whose turn, which cells are already fired) is never wrong. Rock Paper Scissors is the 8th adopter (2026-09-29) (RockPaperScissorsMatch/RockPaperScissorsMatchManagergained the same shape;submitMove()now rejects a move server-side while the opponent is mid-grace-period, same freeze check as Battleship'sfire()) - a third distinct per-game accommodation, not a repeat of either earlier one: RPS has no board and no turn at all (moves are blind and simultaneous), so neithermySymbolnorboardStatecarries anything meaningful by default -RockPaperScissorsMatch.onReconnect()instead packs the running score intoReconnectResult.boardStateas"myScore:opponentScore", whichresumeMatchIfPending()'s newif ("rock-paper-scissors".equals(gameId))branch unpacks into the synthetic push'srpsMyScore/rpsOpponentScorefields before it's replayed (client-side,RockPaperScissorsWindow'sRPS_MATCH_FOUNDhandling was reading those two fields as an unconditional reset to 0-0 - now reads them off the message instead, a one-line fix that costs a genuinely fresh match nothing since those fields are simply unset/0 on one). No generic*_UPDATEmessage needed either, same as Battleship: the live "resume" push to the still-connected opponent just reusesRPS_MATCH_FOUNDitself (with realrpsMyScore/rpsOpponentScoreset, unlike the login-response DTO's repurposed boardState) since there's no board/turn state a second message would need to correct. Verified with a 15-check scratch test covering the same six scenarios Battleship's did. That's all 7 of the platform's 2-player, forfeit-based online games covered. Trivia Blitz is the one online-multiplayer game left with no reconnection story, but audited (2026-09-29) and found NOT to be a small next adopter: it's 2-6 players andTriviaMatch.handleDisconnect()already deliberately keeps a match running (score locked in) rather than forfeiting on a disconnect, soReconnectRegistry's one-opponent/grace-period/ forfeit shape doesn't actually fit it - seeBLOCKED_QUESTIONS.mdfor the real, un-guessed design question this raises. Genuinely continuous-simulation games (Racing, Space Battle, Air Hockey, Fight Arena, Zombie Survival) and Chess (deliberately not yet adopted, see above) remain separate, lower-priority cases.TournamentManager.java— 4-player single-elimination bracket for Battleship and Rock Paper Scissors only (both always produce a decisive winner).TeamTournament- Manager.java— team version for Fight Arena's 2v2/3v3, registered by wholesocial.Party, deliberately a single decisive match rather than a full bracket.ReplayManager.java— Chess-only for now, persists full board snapshots per match, stepped Prev/Next client-side. Battleship and Rock Paper Scissors have their own separately-shaped replay viewers, not generalized throughReplayManager.FileHash.java(shared) — SHA-256 helper backing the auto-update jar-hash check.GameRules.java/GameRulesDialog.java— static per-game "how to play" text + popup.GameDetailPanel.java(replacedGameDetailDialog, 2026-09-29) — the "about this game" step as a real page inMainMenu'sCardLayout(Pages.GAME_DETAIL), not a modal popup.MainMenu.showGameDetails(game)builds one and remembers which page opened it; Back/Escape returns there (Games page if it was opened from inside a running game). Client-only. Wraps its rules text with an HTML table width because<body style='width:..'>is ignored on newer JDKs.- Shared dialog chrome reused across many games:
GamePickerDialog,ConnectDialog,ServerBrowserDialog,SpectateDialog(Chess-only),RematchOfferDialog,ReplayBrowserDialog. (HostServerDialogwas removed 2026-09-25 along with the in-app hosting feature - see above.) TriviaLiveLookups.java— plain holder bundling the 5ai.knowledgeCachingFactLookupinstancesGameServerbuilds once, threaded throughTriviaMatchManagerinto everyTriviaMatch.
Bot-AI-bearing games bridge into the ai package: TicTacToePracticeMatch +
TicTacToePracticeBotStrategy/TicTacToeRandomMoveStrategy; BattleshipWindow +
BattleshipBotStrategy/BattleshipAI/BattleshipRandomShotStrategy;
RockPaperScissorsWindow + RockPaperScissorsBotStrategy/
RockPaperScissorsFixedMoveStrategy; MazeChaseGame +
MazeChaseChaserBotStrategy/MazeChaseChaserRandomStrategy/MazeChaseChaserState —
all register primary+fallback strategies with ai.AiKernel. ConnectFourWindow,
ReversiWindow, DotsAndBoxesWindow, CheckersWindow, ChessWindow, and
SignalGridWindow do too, but via the shared ai/search engine instead of bespoke
per-game bot classes: each brings only a GameModel adapter
(ConnectFourGameModel/ReversiGameModel/DotsAndBoxesGameModel/CheckersGameModel/
ChessGameModel/SignalGridGameModel) and registers ai.search.GenericBotStrategy +
ai.search.RandomMoveStrategy as its primary/fallback pair. Checkers and Chess are
parameterized over small custom state/move pairs (not a plain char[]/Integer like
the other four) since their real rules need more than a board: Checkers' mandatory-
multi-jump rule needs to know which piece must keep capturing; Chess needs castling
rights, the en passant target square, and (uniquely among all six) whose turn it
currently is, since checkmate/stalemate depend on that and not just the board.
ChessWindow previously had no offline mode at all ("a real chess engine is a much
bigger undertaking on its own" - no longer true once the shared engine already existed
for other games); it gained a mode-select screen, matching the rest, rather than the
single always-online layout it had before. Memory Match, Fusion Grid, Word Duel, and
Dice Duel don't fit this engine's perfect-information, deterministic-transition
assumptions (hidden card values, random future draws, or randomness mid-decision), so
each got its own bespoke heuristic bot instead of a GameModel. Word Duel
(WordDuelBotStrategy/WordDuelFallbackStrategy, scoring words via
WordDuelWordList.bestWordFrom/anyWordFrom) and Fusion Grid (FusionGridBotStrategy/
FusionGridFallbackStrategy, scoring each empty cell via a pure simulateCascade
replay of the real merge rule) are still plain ai.BotStrategy registered with
AiKernel — a bot turn there really is one chooseMove call, non-adversarial (Word
Duel) or heuristic-scored (Fusion Grid), so the existing framework fits as-is. Dice
Duel (DiceDuelBotStrategy) and Memory Match (MemoryMatchBotStrategy) are standalone
classes called directly by their Window, not through AiKernel, because a turn there
is inherently more than one decision: Dice Duel's chooseDiceToHold/chooseCategory
(greedy against DiceDuelMatch.scoreFor) happen across up to two rerolls: and Memory
Match's chooseFirstFlip/chooseSecondFlip need a private, per-match known map fed
by an observe(index, symbol) call on every flip (the bot's own and the human's) so
it only "remembers" what has actually been shown on screen — the one genuinely
hidden-information game here, and deliberately not omniscient. MemoryMatchWindow's
practice mode runs on its own MemoryMatchPracticeMatch rather than the shared
ai/search PracticeMatch, for the same reason: that class assumes both players see
one shared state, which doesn't hold when part of the state (face-down cards) is
genuinely secret.
dominion — Quorix: Dominion's core simulation + networking
Full design in DOMINION_DESIGN.md. Now on CLAUDE.md's byte-identical sync-rule
list (added 2026-09-26 the moment net/Message.java/net/ClientHandler.java
started referencing dominion.* types directly for the networking layer below) -
QuorixClient carries the whole package byte-identical even though, like
TicTacToeMatch and every other match class, the client doesn't actually run the
authoritative logic itself.
Data model (all Serializable - see "Networking" below): Province (one grid
tile - terrain, owning nation id, orthogonal-only adjacency), Nation (V1: one
account per nation - treasury, honor, no council/members yet; isValidName()
rejects "|" and enforces a 2-30 character letters/digits/spaces/apostrophes/
hyphens format, the same pipe-delimited-save-format-corruption bug class
ServerAccountStore.isValidUsernameFormat already guards against for usernames),
Army (single generic unit type, a marchOrderTargetProvinceId holding a queued
order until the next tick), RelationType/DiplomaticRelation (NEUTRAL/ALLIANCE/
NON_AGGRESSION/WAR between two nations - a declared WAR is deliberately not
immediately active, effectiveFromTick is always current tick + 1, a strategic-
pacing choice not a technical one).
Simulation: DominionWorld (all live state in memory, plus foundNation() -
validates the name, that the account doesn't already have a Nation, and that the
target province is unclaimed, then allocates a Nation id and claims the province;
toSnapshot() builds the client-facing DominionSnapshot) and DominionTickEngine
(the once-per-day resolution pass: advances the clock, then resolves every army's
standing march order - an uncontested claim onto unclaimed territory, a simple
relocation onto the army's own territory, or a decisive win/loss combat resolution
when marching into contested enemy territory during an active war - combat power
is troop count x a terrain defense multiplier, no partial attrition or unit-type
variety yet, matching V1's intentionally small scope). Proven with a 16-check test
covering exactly the scenario the design's build order calls for (two nations, a
declared war, an army march, a combat outcome, a province flipping ownership) plus
edge cases.
Persistence: DominionStore (save/load for a DominionWorld - its own
isolated flat file gamehub_dominion.dat, same pipe-delimited/type-tagged-line/
backward-compatible-by-field-count convention as ServerAccountStore, one line
per record with a leading tag TICK/PROVINCE/NATION/ARMY/RELATION since
this one file holds every entity type rather than one file per type). Proven with
a 20-check test: a full save-then-reload round trip for every entity type
including a declared-but-not-yet-active war (must survive exactly, not get
recomputed relative to the new tick), a real mid-game scenario continuing to
resolve correctly after a reload, and loading with no file present yielding a
fresh empty world rather than erroring.
Networking (build order step 3, now complete - 2026-09-26): DominionManager
is the single whole-server front door - unlike every other game's per-match
manager, there is exactly one DominionWorld for the entire server (loaded once
at startup via DominionStore; seeds a deterministic placeholder 10x10 map on
first launch if the loaded world has no provinces, since an empty world has
nothing to found a Nation on - the real map size/terrain distribution are still
"deliberately not decided" per DOMINION_DESIGN.md, pending playtesting). Seven
message-type pairs: DOMINION_FOUND_NATION_*/DOMINION_STATE_* (first slice -
found a nation, fetch a full world snapshot to render) and, same day,
DOMINION_RECRUIT_ARMY_*/DOMINION_QUEUE_MARCH_*/DOMINION_DECLARE_WAR_*/
DOMINION_PROPOSE_RELATION_*/DOMINION_RESPOND_PROPOSAL_* (second slice - every
remaining V1 order type). Every one of DominionWorld's account-facing methods
(recruitArmy/queueMarchForAccount/declareWarForAccount/proposeRelation/
respondToProposal) independently re-verifies ownership server-side - an army,
province, or Nation id a client claims is never trusted, same "server is sole
authority" rule as every match game. queueMarch/declareWar themselves are now
package-private internal mutators; the account-validated *ForAccount wrappers
are the only entry points ClientHandler (or a test) should call.
DominionSnapshot is the client-facing DTO for state responses - V1 has no fog
of war (see DOMINION_DESIGN.md's Future Depth section) so province/nation/army/
relation data deliberately includes everything, not just the requester's own. The
real domain objects (Province/Nation/Army/DiplomaticRelation) double as
the wire format directly rather than a parallel read-only view hierarchy, since
V1 has nothing to hide yet - a real DTO split becomes worth it once fog of war is
built. The one exception is getMyProposals(): a new DiplomaticProposal class
(a pending Alliance/Non-Aggression offer awaiting accept/reject) is scoped to
just the requesting account's own Nation (DominionWorld.toSnapshot(accountId))
rather than broadcast to everyone - a proposal genuinely is private between the
two nations involved, the one place V1 already needs "not everyone sees
everything" before real fog of war exists. DominionStore persists proposals too
(a PROPOSAL line, same format), so a pending proposal survives a server
restart.
Every concrete class reachable from a serialized Message (including
DominionSnapshot/DiplomaticProposal and everything inside them, plus boxed
Integer for the several nullable id fields) is explicitly allow-listed in
QuorixSerializationFilter - forgetting an entry here is a real, previously-
undocumented failure mode (QuorixSerializationFilter's own javadoc: a missing
class "silently fails to deserialize"), so both slices were verified with real
ObjectOutputStream/ObjectInputStream round trips through the actual filter
(not just a compile check), across a 29-check test (first slice) and a 51-check
test (second slice) also covering every success/failure outcome of every new
DominionWorld method and a DominionStore round trip that now includes
proposals.
Offline-war protection and a real tick schedule (2026-09-27): two design
facts settled the same night they came up, closing the gap between
DOMINION_DESIGN.md and what actually ran. DeclareWarResult gained
TARGET_OFFLINE; declareWarForAccount now takes a targetIsOnline boolean and
refuses to declare war on it (checked after TARGET_NOT_FOUND, since a
nonexistent Nation should never even ask whether it's "online") - nobody should
come home from being away to find themselves attacked while they couldn't
respond. DominionWorld itself has no view of who's connected, so it can't
compute that boolean; ClientHandler.isDominionNationOnline() does, the same way
FriendManager's online-status features already do - look up the target
Nation's accountId via the new DominionManager.getNation(id), resolve the
Account via ServerAccountStore.findById(), then check
ChatManager.findByUsername() for a live session. Separately, DominionManager
now actually runs the tick: a daemon thread (startTickScheduler()/tickLoop(),
the same infinite-loop-with-sleep shape GameServer.acceptLoop() already uses,
rather than this codebase's first Timer/ScheduledExecutorService) calls
DominionTickEngine.resolveTick() every 20 real minutes (one in-game day, per
the design brief) and persists the result - before this, nothing ever advanced
the clock outside a test. A tick's exception is caught and logged rather than
left to silently cancel every future tick, the same reasoning acceptLoop's own
comment gives for a transient accept() failure. Adding a second kind of thread
that mutates the one whole-server DominionWorld (a tick, concurrently with any
in-flight ClientHandler request) surfaced a real, previously-latent gap: none
of DominionManager's methods were synchronized, meaning two ClientHandler
threads (or a tick and a request) could already race on world's internal maps
before this - every public method that touches world is now synchronized,
closing that race rather than deepening it. Verified with a 13-check test:
TARGET_OFFLINE returned for an offline target and not for an online one, that
TARGET_NOT_FOUND/NO_NATION/CANNOT_DECLARE_ON_SELF still take priority over
the online check exactly as before, and that a fresh DominionManager starts
its tick thread and serves getNation()/declareWar() correctly without
blocking on it. Both trees compile clean and stay byte-identical.
Next: the client's persistent nav tab/map UI (build order step 4) - nothing client-visible exists yet, all of this is server request-handling only.
ai — four independent toolkits, unified by one philosophy
Nearly every class in this package states the same rule in its javadoc: advisory only, never touches real game state directly — a bot proposes a move, the game's own Match/Game logic validates and applies it like any other input.
- Bot-move framework —
BotStrategy.java(one-method interface:chooseMove(state), must not mutate state) andAiKernel.java, the single shared entry point every bot goes through.register(gameId, primary, fallback)+chooseMove(gameId, state)for strategies safe to share as one long-lived instance (e.g. Tic-Tac-Toe Practice).applySafely(primary, fallback, state)is the lower-level primitive for bots needing per-match private memory (e.g. Battleship's hunt/target memory — a fresh strategy instance per match). Either way: try primary, fall back silently on anyRuntimeExceptionso a buggy "smart" strategy can never stall a live match; if both fail, throwsAiDecisionException. Used by Tic-Tac-Toe Practice, Battleship, Rock Paper Scissors, Maze Chase. ai/search— the shared search engine every game plugs into for a real algorithmic AI opponent, without writing its own search or bot-plumbing code.GameModel<S, M>is the only thing a game implements:legalMoves,applyMove,isTerminal,nextPlayer,evaluate,winner— pure rules, no AI logic. A forced pass (e.g. Reversi) is just a synthetic move inlegalMoves, so passing is a per-game rule detail the engine never needs to know about.Minimax.javais the one alpha-beta search algorithm, shared by every game rather than reimplemented per game.GenericBotStrategy/RandomMoveStrategyare the one primary/fallbackBotStrategypair every game registers withAiKernel, parameterized by that game's ownGameModel— no<Name>BotStrategyclass per game.PracticeMatch<S, M>is the one offline-match turn/state wrapper every practice mode uses (apply move → check terminal →nextPlayer, plussuggestMove()for a Hint button that reuses the exact same registered strategy as the bot opponent) — no<Name>PracticeMatchclass per game either. Adding AI to a new game costs one smallGameModeladapter (e.g.ConnectFourGameModel.java,ReversiGameModel.java— each under 200 lines) plus a few lines wiring a "Practice"GameModeCardinto that game'sWindow; everything else is shared infrastructure, written once. Existing Tic-Tac-Toe/Battleship/Rock Paper Scissors bots predate this and are left on their own bespoke code rather than retrofitted, since they already work.ai/grid— generic pathfinding, deliberately independent of any one game's own direction type (an adapter class translates).GridDirection.java(UP/DOWN/LEFT/ RIGHT),GridPathfinder.java(4-directional BFS over a caller-supplied obstacle callback:firstStepTowards(...)for shortest-path chasing,distanceField(...)for a full distance map used by "flee to the farthest reachable cell"). Recomputes from scratch each call — fine at Quorix's grid sizes (Maze Chase is 19×15). Used byMazeChaseChaserBotStrategy/MazeChaseChaserState, replacing an old one-step Manhattan-distance heuristic that could walk chasers into walls that looked closer in a straight line but weren't reachable that way.ai/steering—Steering.java, pure closed-form vector math (seek()/flee()). NoAiKernelregistration — nothing here can throw, so there's no fallback to wire up. Used byZombieSurvivalGamefor each zombie's per-frame movement toward the player.ai/knowledge— the live trivia lookup system, used exclusively by Trivia Blitz.FactSource.java(interface:lookup(subject), returnsnullon any failure — unknown subject, no network, timeout, malformed response — collapsed uniformly since the caller's fallback is the same regardless of cause).CachingFactLookup.java(cache-first: checkFactCache→ on miss call theFactSource→ retry once on failure → persist a hit;keyPrefixnamespaces categories sharing one cache format).FactCache.java(flat pipe-delimited on-disk store per category, loaded once, appended immediately per new entry).WikidataSparqlClient.java(generic client for Wikidata's public SPARQL endpoint; runs arbitrary SELECT text, extracts the first result row via a small regex rather than a full JSON library).WikidataFactSource.java(aFactSourcewrapping one SPARQL query template with a{subject}placeholder + result-variable name — one reusable class parameterized per category, escaped against SPARQL string-literal injection).RestCountriesCapitalSource.java(aFactSourcehitting restcountries.com for country→capital, hand-rolled regex JSON extraction).GameServerwires oneWikidataSparqlClient+ 5CachingFactLookups (capital via RestCountries; company-founding-year/inventor/historical-event-year/city→country via Wikidata SPARQL templates) into aTriviaLiveLookups, threaded intoTriviaMatchManager. Status as of this doc: the real HTTP/SPARQL calls inRestCountriesCapitalSourceandWikidataSparqlClienthave not yet been exercised against the live internet from any environment available so far (both the original dev sandbox and this session's own environment restrict outbound HTTPS to an allowlist that excludes restcountries.com/query.wikidata.org) — only the surrounding cache/retry/fallback orchestration has been verified, against fakeFactSources. Needs real-network verification (e.g. via BlueJ) before being trusted in a live match.
engine — shared sprite/animation/game-loop/collision toolkit
Client-only (like ui/theme/pages — not mirrored into QuorixServer, confirmed via
/tmp/keep_files.txt), and unused by any shipped game so far: built minimal-first,
same philosophy as ai/search — a small number of focused, independently useful
classes rather than one big framework, aimed at making a simple new game buildable in
a few hundred lines instead of starting every game from scratch. Every existing game in
this codebase already hand-rolls its own version of this exact plumbing (a private
javax.swing.Timer field, its own ballX/ballVx-style physics, its own AABB overlap
checks) — engine doesn't retrofit those working games, it's for whichever game reaches
for it next.
GameLoop.java— a named wrapper around the samejavax.swing.Timer-driven tick loop every offline game already hand-rolls (BrickBreakerWindow,FightArenaPanel,TetrisWindow, ...) — same mechanism (fires on the EDT, so game state/physics/repaint all stay safely on the UI thread with zero extra synchronization), just one shared name/API.Ticker.tick(dtSeconds)hands real elapsed time for frame-rate-independent motion; a game that prefers the existing codebase convention of moving a fixed amount every tick (BrickBreakerGame.ballX += ballVx) can simply ignoredt— both styles work with the same loop. Deliberately not a second thread or a fixed-timestep-with- catch-up loop — this codebase has no game needing sub-frame accuracy, and a second thread touching Swing components would break the single-threaded rule Swing needs everywhere else in Quorix.Vector2.java— immutable 2D vector (add/subtract/scale/dot/length/normalize/rotate/reflect/angle). Most existing games move objects with plaindouble x/y/vx/vyfields instead (seeBrickBreakerGame,AirHockeyWindow) — that's still fine, andGameObjectuses that same convention for its own position/velocity.Vector2is for where plain doubles get awkward:pseudo3d's raycasting needs real vector rotation for the camera direction/plane, andCollision.bounceOffSidereturns one for angled bounce responses.GameObject.java— optional base class for a moving, collidable thing (a ball, a paddle, an enemy). Plainx/y/vx/vy/width/height/alivefields matching every existing game's own convention;update(dt)integrates velocity into position by default (override for gravity/AI-steering/scripted motion),bounds()returns aRectangle2D.DoubleforCollision's checks. Entirely optional — a game can keep its own plain fields and callCollision's static methods directly instead.Collision.java— the same handful of overlap tests and bounce responses every physics-y game already hand-rolls per-game (BrickBreakerGame.checkPaddleCollision/checkBrickCollision,AirHockeyMatch's puck-vs-paddle), written once. Static utility likeMinimax/GridPathfinder—aabbOverlap,circleOverlap,circleRectOverlap(clamp-the-circle's-center approach, more accurate near corners than approximating a circle as its own bounding box the wayBrickBreakerGamedoes today),overlapSide(which edge of a rect a circle is hitting from — the exact questioncheckBrickCollisionanswers by hand), andbounceOffSide(flipsvx/vyoff that side, returned as aVector2).Sprite.java/SpriteSheet.java/Animation.java— no game in this codebase currently draws image-based sprites (every board/piece is hand-painted withGraphics2DfillRect/fillOval/etc. — seeReversiWindow,BrickBreakerWindow); these exist for the games that will want actual bitmap art without each one reinventing "load an image, slice a sheet into frames, cycle them over time."Spritewraps oneBufferedImageframe (also useful for a game that renders complex vector art once into an offscreen buffer and blits it every frame instead of repainting the same shapes every tick, the wayGameLogocaches its rendered icon).SpriteSheetslices a grid-laid-out image into frames, loading from the classpath (Class.getResourceAsStream, the same portable-jar-friendly approachGameLogo.loadSource()uses forquorix_logo.png) or wrapping an already-loadedBufferedImagefor procedurally-drawn sheets.Animationadvances through a frame sequence on the samedtGameLoophands itsTicker; loops by default, orloop=falsefor a one-shot effect (an explosion) withisFinished()to know when to remove it.engine/pseudo3d— the planned pseudo-3D capability: isometric/raycasting tricks in plain Java2D, deliberately not real 3D (JOGL/LWJGL/a native rendering pipeline was ruled out as breaking the "one portable jar, no install" model everything else follows).RayCaster.java— classic Wolfenstein-3D-style raycasting via the standard DDA grid-traversal algorithm: one ray per screen column against a 2Dint[][]map, rendered as vertical wall slices with a flat per-cell color (darkened on one wall orientation for cheap directional shading) rather than real textures. The caller owns the camera (posX/posY, a unitdirX/dirY, and aplaneX/planeYperpendicular todirwhose length sets the field of view) — rotating the view is just rotatingdirandplanetogether by the same angle viaVector2.rotate.IsometricProjection .java— converts tile(col,row)to on-screen pixel position for the classic 2:1 diamond-tile look and back (screenToTile, for mouse picking), plusdrawOrder(col, row), the standard isometric painter's-algorithm sort key (draw increasingcol+rowfirst so nearer tiles correctly overlap farther ones without a real depth buffer).- Verification: a scratchpad smoke test (
EngineSmokeTest.java, not part of the shipped codebase) exercises every class directly — vector math identities, collision overlap/bounce correctness, a procedurally-built sprite sheet sliced and animated through a full loop cycle plus a one-shot animation finishing correctly, a raycaster render against a small test map confirmed to actually paint wall-colored pixels, an isometric tile↔screen round-trip, and a realGameLoopstarted/stopped/confirmed to fire real ticks — 39/39 checks passed. A separate visual demo (RayCasterVisualDemo.java) rendered both a raycaster corridor view and an isometric tile grid to PNG and was eyeballed to confirm they actually look like the intended pseudo-3D style, not just "some pixel matched." - First real adopter:
HillClimbGame/HillClimbWindow(seeROADMAP.md's "Done" section) usesVector2(resolving gravity along the terrain's slope angle via.rotate) andGameLoopfor its tick loop — proof the design generalizes to an actual game, the same validation stepai/searchwent through withConnectFourGameModelbefore being trusted more broadly. - Not built yet (still just
ROADMAP.mdideas, not started): a particle system for effects (explosions, bursts) — skipped for this minimal-first pass sinceConfettiOverlayalready covers the one existing burst-effect need and a generic particle system isn't yet justified by a second use case.Sprite/SpriteSheet/Animationandengine/pseudo3dstill have no adopter game yet -HillClimbGameonly neededVector2/GameLoopso far.
economy — coins, ratings, achievements, cosmetics
Hook-in pattern: EconomyManager, GameHistoryManager, LeaderboardManager are
constructor-injected into every <Name>MatchManager; each <Name>Match calls back into
them at match end. AchievementManager is wired as a secondary dependency into those
three (not into match classes directly), so achievement checks piggyback on calls that
already happen rather than needing their own call site in every match.
EconomyManager.java— the coin-award entry point:awardWin(ClientHandler, gameId)looks up the reward viaEconomyConfig, credits the account, logs viaTransactionManager, records challenge progress. Placement games (Racing, Space Battle) don't have a singleawardWin-shaped winner, so they go throughawardPlacement(player, gameId, place, activityLabel)(only 1st place counts as a win) instead — one method now, not two byte-identical copies (awardRacingPlacement/awardSpaceBattlePlacementused to duplicate each other exactly; merged 2026-09-26, seeEconomyKernel.java). Tie-splitting games (Square Wars, Trivia Blitz) go throughawardMatchWinCoinsinstead, since their per-winner split is genuinely computed per-game. All three funnel into the same sharedrecordOnlineWin()tail asawardWin, so every online game's win reachesChallengeManagerthe same way. Also handles shop purchases (always validated server-side — never trust a client-reported balance) and daily login rewards.EconomyKernel.java— the one class a new game's server-side code should actually read to answer "how do I pay this player?", added 2026-09-26 after finding several ofEconomyManager's per-game methods (awardSnakeScore,awardPuzzleQuestCompletion,awardMinesweeperCompletion) were near- or byte-identical copies of its own already- genericawardCoins/awardPracticeScore— genuine duplication a new game's author had no way to notice without reading all of them first. A static facade (same "static utility over an existing manager" shape asPerformanceMode/EconomyConfig, not an injected instance) exposing exactly four reward shapes:awardCompletion(score-scaled offline game — add the formula toEconomyConfig.getPracticeRewardand nothing else needs an edit),awardFlatCompletion(solved-or-not games with no meaningful score, like Sudoku/Mine Grid/Puzzle Quest),awardMatchWin(a standard single-winner online match), andawardPlacement(race/FFA placement, top 3 only). A fifth, rarer shape (several players tie and split a pool) has no wrapper yet — callEconomyManager.awardMatchWinCoinsdirectly, since only two games need it and the split math is genuinely per-game. ExistingEconomyManager.awardWin(...)call sites across the ~30 match classes were deliberately left as direct calls rather than mass- migrated toEconomyKernel.awardMatchWin— that method is already a clean, non- duplicated single entry point, so routing it through the kernel too would be a large, purely cosmetic rewrite with no bug to fix; new code goes throughEconomyKernel, existing directEconomyManagercalls remain equally correct underneath.EconomyConfig.java— pure static config: per-game win-coin table, practice-mode score→coin formulas (Snake folded in here 2026-09-26 — seeEconomyKernel.java, it used to be its owngetSnakeReward()special case), one shared placement-reward table for Racing/Space Battle, the 7-day daily-login streak table, challenge definitions, shop catalog. The one place all economy numbers live.getWinReward()'s table was missing a "trivia-blitz" entry until 2026-09-29 —TriviaMatchhad always looked it up to compute its winner pot, so the game had been silently paying zero coins since it shipped; found by grepping everyMatchclass for its actualEconomyManager.awardWin(...)/EconomyConfig.getWinReward(...)call site to get the real list of 24 games this table needs to cover (seeROADMAP.md's "Done" section), not by guessing from the games list.QuorixServerTests/economy/ EconomyConfigTest.javanow locks that full set in.LeaderboardManager.java— per-game ELO (K=32, start 1200) for symmetric 1v1 games; a pairwise-ELO approximation for Fight Arena's N-player matches; separate best-score tracking for score-based games. Impostor Ship is deliberately excluded from ELO (asymmetric roles don't map to a symmetric skill rating).AchievementManager.java— permanent, silent unlocks computed from three already-tracked metrics: win counts, total plays, coin balance. Data-driven since 2026-09-26 (the "achievements kernel" requested alongsideEconomyKernel): eachDefinitioncarries its own trigger - a threshold on a named metric ("wins:chess" >= 5) or a one-shot event key ("racing:place1") - and the two generic entry pointscheckThreshold(accountId, metric, currentValue)/checkEvent(accountId, eventKey)unlock whateverDefinitions match, so a new achievement is oneDefinitionline, never a new method or another branch in a hand-maintained if-chain. The original 6 named check methods (checkWinAchievements,checkRacingPlacement, ...) stay as thin wrappers over those two - unlikeEconomyKernel, no separate facade class was needed here, since this public API was already clean rather than duplicated; existing call sites needed zero changes.GameHistoryManager.java— records every play event, feeds "Recently Played"/"Trending" and achievement play-count checks.TransactionManager.java— flat coin-transaction audit log.ChallengeManager.java+ChallengeProgressInfo.java(shared) — daily/weekly/ permanent challenge progress; definitions come fromEconomyConfig.- Cosmetics/avatars:
AvatarStore(server, one PNG per account),AvatarCache(client fetch cache),AvatarEditorDialog(in-app paint tool),AvatarFrameRegistry/PlayerColorRegistry(client caches of shop cosmetics),ShopItemDefinition(server entry) /ShopItemInfo.java(shared). - Local-only convenience stores (
java.util.prefs.Preferences, no server round trip):PinnedGamesStore,PinnedFriendsStore,LastGameModeStore,SavedServersStore,GuestPlayTracker(queues logged-out Snake plays, flushed on next login),FpsCounterSetting,NotificationSoundSetting,PerformanceMode(checked intheme/UITheme.applyAntialiasing(),GlitchEffectOverlay, and real-time games' tick intervals). FpsTracker.java— per-panel frame-rate counter.AchievementToast.java— non-modal achievement-unlock notification.
account vs social — identity/auth vs. relationships/presence
account never imports social; social (FriendManager, ModerationManager)
imports account to resolve who a username belongs to. GameServer composes both
independently and hands both into ClientHandler.
account = who you are and how you prove it:
Account.java (shared) (permanent accountId that never changes even across
username changes, plus role/coins/cosmetics), Role.java (PLAYER/MODERATOR/ADMIN, on
Account not username), PasswordHasher.java (SHA-256 + per-account salt),
ServerAccountStore.java (server-side account CRUD/login, lockout after failed
attempts), Session.java (client-side "who's logged in"), PermissionManager.java
(client-side-only convenience checks — its javadoc stresses this is not security;
ClientHandler re-checks role server-side for every privileged request), plus the
auth/profile UI (AuthWindow, LoginPanel, CreateAccountPanel, ChangePassword-
Dialog, ChangeUsernameDialog, ProfilePanel/PlayerProfileDialog — no coin balance
shown on other players' profiles).
social = relationships and live communication once you're in:
FriendManager.java (requests/list, presence broadcast on login/logout,
getMutualFriendUsernames(a, b) powering the profile dialog's "Mutual Friends"
section),
ChatManager.java (tracks connected clients by username, DM routing, broadcast),
GroupChatManager.java, PartyManager.java/Party.java (play-together groups,
feeding TeamTournamentManager), ModerationManager.java (mute/kick/ban, keyed by
username not accountId to catch renamers, plus a report queue), and dialogs
(FriendPickerDialog, NewDirectMessageDialog, NewGroupDialog, GameInviteDialog,
ReportPlayerDialog, ScoreShareDialog, PartyDialog, ModChatDialog - a staff-only
channel reachable from ModeratorPanel, gated server-side by
ClientHandler.isModeratorOrAdmin() for both sending and who a MOD_CHAT_MESSAGE
gets broadcast to; no message history yet, only what's sent while it's open).
mechanics — shared cross-game systems
Added 2026-09-30, shared (byte-identical) between both trees. The home for behavior that is
the same for many games and shouldn't be re-implemented in each - the first residents are the
reconnect pieces; the next candidates are listed in ROADMAP.md.
ReconnectPolicy.java- the rules, in one place: the grace window (GRACE_SECONDS = 30,GRACE_MS), the exception table (EXCEPTIONS, game id -> reason; the eight group games - Racing, Space Battle, Square Wars, Zombie Survival, Impostor Ship, Telephone, Trivia Blitz, Fight Arena - where "forfeit" doesn't apply),isEnabled(gameId), andcanReconnect(handler, gameId): true only for a logged-in player who actually dropped (!handler.isLeavingVoluntarily()) in a game that isn't an exception. Moving a game in or out is a one-line change here.waitingNotice()is the text the waiting player sees.ReconnectRegistry.java(moved here fromgames/) - the grace-period timer, keyed by accountId, plusReconnectableMatchandReconnectResult. There is one server-wide instance,ReconnectRegistry.shared()(a player can only be waiting in one match), soClientHandler.tryReconnectAllGames()is a single call and a new game needs no plumbing at login. The managers'getReconnectRegistry()still exist and return the shared one. Lock order rule (unchanged): register with it only after releasing the match's own lock.PairReconnect.java- the whole two-player disconnect / timeout / resume state machine, written once. A match creates one (new PairReconnect(this, GAME_ID, host[, resumeDelayMs])), itshandleDisconnectbecomesreconnect.handleDisconnect(who), and each action starts withif (reconnect.isPaused()) return;(real-time games useisHeld(), which also covers the resume delay). The game supplies only a smallHost:matchId,isOver,player/setPlayer(the two slots are non-final so a returning player's new handler can take theirs),stateString(for the waiting notice),forfeit(remaining)(what "the match ends because of a drop" looks like - result message, coins),resume(slot, opponent)(update the waiting player, describe the match for the returning one) andattach.refreshNotice()re-sends the notice for a match whose state changes during the pause (Memory Match's mismatch timer).resumeDelayMsis for the real-time games: 3s of extra freeze after a return, so the returning player's rebuilt window is up before the puck/snakes/pieces move.- Adopters of
PairReconnect: Dice Duel, Signal Grid, Fusion Grid, Memory Match, Typing Duel, Card Rush (turn-based / simple), Air Hockey, Snake Arena, Block Stack Duel (real-time). Chess (ChessMatch, via the helper'sonPausedhook for its draw offer). The eight earlier adopters (Tic-Tac-Toe, Connect Four, Checkers, Reversi, Dots and Boxes, Word Duel, Battleship, Rock Paper Scissors) also hand-written, now reading their window fromReconnectPolicy. Game-specific packing intoReconnectResult(a login response has no field for these): Memory Matchturn|a:b; Typing Duel sentence inboardStateandwinsA:winsB|progA:progBinturnSymbol; Block Stack Duel own grid inboardStateandscore|opponentGridinturnSymbol; Word Duel / Battleship / RPS as described in thegamessection.pages/MatchResumeunpacks all of them. - Getting a dropped player back in (the part that makes any of this matter): the client
(
net/NetworkManager) now notices a drop as soon as its listener thread ends on a still-live connection - not only when a later send fails - re-opens the socket, and runs a hook;pages/SessionRestorer(installed byMainMenu) uses it to log in again with the credentials cached in memory for this run, andpages/MatchResume(extracted fromAuthWindow, which now calls it too) rebuilds the game window from the login response viaMainMenu.showResumedGame, which first unhooks any stale window without sending a leave. Server side,ClientHandler. handleLogintakes over the same account's older session's reconnect-eligible matches (releaseMatchesForTakeover()) before trying to resume - a dead connection the server hasn't noticed (Wi-Fi drops send no close) still has its match bound to it. Group games are not touched by a takeover. - Dead-connection detection (2026-09-30):
PING_REQUEST/PONGevery 8s fromNetworkManager's heartbeat thread;ClientHandlersets a 25sSO_TIMEOUTand treats a timeout as a disconnect (so the grace period starts for the opponent's benefit), the client a 30s one on its own socket. A client that never pings is dropped after 25s idle. - Countdown and chat:
ui/ReconnectCountdown(client-only) ticks the notice's "up to 30s" down on every game window and stops by itself when the label is rewritten.chat/MatchChatRoomkeeps a static registry of open rooms; login callsrejoin(handler)for a resumed match, andMatchChatRoom.sendStateToanswers the client'sMATCH_CHAT_SYNC_REQUEST(sent byMatchResumeafter the window is up) so the dock comes back. - Chess now uses
PairReconnecttoo (itsonPausedhook clears a pending draw offer). The eight earliest adopters keep their hand-written state machine deliberately.
chat — in-match chat rooms, with per-game restrictions
(Also home of CalcParser, the local evaluator behind /calc - used by pages/ChatPanel and MatchChatDock, never sent to the server; shared between the trees only so CalcParserTest can reach it.)
Added 2026-09-29. A small chat room shared by everyone in one live match - separate
from the DMs/group chats above - shown as a dock beside the game. Server-authoritative:
a client asks to send (MATCH_CHAT_SEND_REQUEST), the room decides.
ChatRestriction.java(shared) —OPENorLOCKED.MatchChatRoom.java(shared) — members + current restriction.post(sender, text)relays to every member only if the room is open, the sender is a member and the text isn't empty (trimmed to the same 500-char cap as DMs);lock()/unlock()/leave()/close()/closeAfter(ms). Every state change is pushed asMATCH_CHAT_STATE("OPEN"/"LOCKED"/"CLOSED"), which is also how a client learns a room exists. Attaches itself to each member'sClientHandler(getMatchChatRoom()), which is howhandleMatchChatfinds it; mute and flood limits are applied there first, exactly as for DMs.GameChatPolicies.java(shared) — the "package to restrict easily": one table of which games have chat and how it starts. Give a game chat = add one line; restrict it = make the lineLOCKEDand callroom.unlock()from the game; remove chat = delete the line. Opt-in on purpose (a game not listed has none), so a game where free chat would leak hidden information never gets it by accident.openRoom(matchId, gameId, members)creates and announces a room, or returns null for an unlisted game.MatchChatDock.java(client-only) — the Swing dock.MainMenucreates it on the firstMATCH_CHAT_STATEfor a match, feeds itMATCH_CHAT_MESSAGEs, ignores other match ids, and drops it whenever the game-host slot is cleared. It only mirrors the server (a locked room disables its input) - enforcement isMatchChatRoom.post. Collapsible to a thin strip with an unread count.
Who has it today: the 14 games on MatchmakingKernel (Checkers, Connect Four, Reversi,
Dots and Boxes, Word Duel, Dice Duel, Typing Duel, Air Hockey, Memory Match, Signal Grid,
Fusion Grid, Card Rush, Snake Arena, Block Stack Duel - MatchmakingKernel opens a room when
it pairs two players and keeps it open 60s after endMatch for a "gg"), and Telephone
(starts LOCKED because free chat would let players say the answer out loud;
TelephoneMatch unlocks it when the reveal starts and keeps it 10 minutes). Not yet:
Tic-Tac-Toe, Chess, Battleship, Rock Paper Scissors, and every group/real-time game -
each needs its Match to call GameChatPolicies.openRoom(...)/room.close() itself.
forum — Forums (boards, threads, replies)
Added 2026-09-29, shown as the Forums tab in the sidebar (Pages.FORUMS). A board per
game (every id in GameRegistry) plus General; a thread is a title + opening post, with
flat (non-nested) replies. Reading works for anyone connected; posting needs a login.
ForumCodec.java(shared) — one-line, tab-separated records with every field escaped (backslash/tab/newline/CR), used for both the on-disk file and the lists sent to clients. Because no field can contain a raw tab or line break, a post can't split into a second record or forge extra fields on reload - the same bug class already fixed once inGameSuggestionStore.threadSummaryLine/postLineare the wire forms.ForumThread.java/ForumPost.java(shared) — the data. Everything the store hands out is a copy.ForumStore.java(shared) — threads + posts in memory, persisted togamehub_forums.txt(git-ignored runtime data via the existinggamehub_*.txtrule), rewritten via a temp file + move on every change. Strictly increasing timestamps so "newest activity first" never ties. Load skips unparseable records, orphan posts and threads with no opening post instead of failing.ForumService.java(shared) — the rules: valid boards only (fixed at startup); title 1-100 chars forced onto one line; post 1-2000 chars keeping line breaks but dropping control characters; too-long text is rejected with a message, never silently cut; a locked thread takes no replies; 500 posts per thread; a board lists at most 100 threads. Content only - who is asking isClientHandler's job.ClientHandler—handleForum*(FORUM_*_REQUEST->FORUM_RESPONSE): reads are open; posting needs login, not muted, and theforumflood bucket (3 posts / 30s, shared by new threads and replies); delete and lock are moderator/admin only, decided from the account's stored role on the server, and each is written toAdminLog.ForumsPanel.java(client-only,pages/) — board list, thread list with composer, thread view with reply box; re-fetches whenever the page is shown. It only hides buttons that would be refused; the server enforces everything.NetworkManager.RESPONSE_TYPESmust listFORUM_RESPONSE(without it every request times out as an unsolicited push).
Not in the first version: votes, editing, images, notifications, nested replies.
admin — moderation tooling, gated by Role
AdminLog.java— append-only audit trail. A record of what happened, not an access-control mechanism — writes only happen afterClientHandleralready verified the actor's role.FeedbackManager.java/FeedbackDialog/FeedbackListDialog— bug reports/ suggestions about Quorix itself (distinct fromModerationManager's player-conduct reports); admins see everything, everyone else sees only their own. Structured since 2026-09-26: a required title, a description, and (bug reports only, optional) steps to reproduce - not just one free-text box. The savedgamehub_feedback.txtstays genuinely human-readable (aTitle:line and aSteps to reproduce:section, both optional per-entry) with a backward-compatible parser fallback for entries written before these fields existed.GameSuggestionStore.java— public community wishlist of game ideas (text pitches only — no uploaded/executed code).
A real entry-forgery bug found and fixed across all three stores (security
pass, 2026-09-27): GameSuggestionStore and AdminLog both persist one entry
per physical line (load() treats every readLine() result as its own entry);
neither stripped embedded \n/\r from the free text a caller supplies before
formatting and writing that line, so a suggestion (or, lower severity since only
admins/mods can reach it, an admin's typed action reason) containing a line
break could forge an entirely separate, indistinguishable-looking entry under
any fake "[date] username: ..." prefix of the attacker's choosing - including
impersonating another real player - the moment the file next reloads (a server
restart). GameSuggestionStore is the more serious of the two: it's the public,
everyone-sees-it wishlist, reachable by any logged-in player, not just staff.
Fixed by stripping/replacing embedded newlines in the free-text field before it
ever reaches the one-line format. FeedbackManager's block-based format (a
DELIMITER line, not one-line-per-entry) already tolerates embedded newlines
within an entry by design, but had the same bug in miniature: a submission
whose title/text/steps happened to contain a line that was exactly the
64-dash DELIMITER string would falsely end that entry early on the next
reload, misparsing everything after it as a second, differently-attributed
entry - narrower (needs an exact 64-dash line, not just any newline) but
fully deterministic once triggered. Fixed by escaping any such line in a
submission before it's stored, changing nothing about how ordinary embedded
newlines behave. Verified with an 11-check test proving each store still
produces exactly one entry (not two) both immediately after submission and
after a real save-then-reload round trip through a fresh instance, that no
forged entry is retrievable at any point, and (for FeedbackManager) that
ordinary multi-line free text still round-trips intact - a regression check
that the fix doesn't change legitimate behavior. Mirrored byte-identical
across both trees (all three files are in the sync-rule's admin package);
both compile clean.
Gating happens server-side in ClientHandler (isAdmin()/isModeratorOrAdmin()
checked at the top of every admin/mod handler); handleAdminSetRole can promote
PLAYER↔MODERATOR but refuses to grant/revoke ADMIN or touch another admin's role at all
(bootstrap-admin-only by design). Client-side, pages/AdminPanel.java and
pages/ModeratorPanel.java are gated the same way through PermissionManager, but
every such panel's javadoc repeats that this is UI convenience only.
pages / ui / theme — the Swing client shell
Sidebar (restructured 2026-09-30): pages/Sidebar keeps the logo row, quest mini-list and status row pinned and puts the
navigation in a JScrollPane; entries are grouped (beginGroup -> an inner Group with a GroupHeader and a body panel):
Home / Play / Progress / Social / Shop & Community / Account. Collapsed state is stored in Preferences (collapsed.<key>),
selecting a page inside a collapsed group re-opens it, and a collapsed group's header echoes a button's badge
(SidebarButton.isShowingBadge). ui/NavIcons gained Home, Forums, Suggest-a-Game, Dominion and Changelog glyphs.
Caption Chaos (2026-10-01): games/CaptionChaosMatchManager (3-8 player queue, starts at 3) -> CaptionChaosMatch (phases WRITING -> VOTING -> RESULT x3 -> OVER, server Timers that also end early when every connected player has acted; CaptionChaosPrompts is the prompt pool), net/ClientHandler hooks (CAPTIONCHAOS_* requests, currentCaptionChaosMatch, leave/disconnect), client games/CaptionChaosWindow.
Obfuscated build (2026-10-01): build.sh --obfuscate -> QuorixClient-release.jar via ProGuard (proguard/quorix-client.pro); proguard/WireCompatCheck.java
verifies every shared Serializable class still matches the plain jar (name, fields, serialVersionUID). Adding a new shared package means adding it to both
the .pro keep rules and WireCompatCheck.SHARED. Shared data classes should declare serialVersionUID (an implicit one changes when methods are renamed).
Match chat for the hand-written managers (2026-10-01): chat/MatchChatRooms (per manager, per game id: open(matchId, a, b) on match start,
close(matchId) from endMatch, 60s grace) is what MatchManager (Tic-Tac-Toe), ChessMatchManager, BattleshipMatchManager and
RockPaperScissorsMatchManager use; MatchmakingKernel still does the same inline. GameChatPolicies stays the one place that says which games get chat.
Design system (2026-10-05 "artifact" restyle): themes QuorixLightTheme / QuorixDarkTheme (+ the older ones) behind ThemeManager (persists the choice under the theme pref; SYSTEM = follow
theme/OsAppearance; helpers onAccent(), mix(), isDarkTheme()); UITheme loads the bundled Inter (QuorixClient/fonts, packaged under /fonts/ by build.sh; bold()/semibold() helpers), radii 18/12.
Chrome: RoundedPanel (fill + hairline border, setElevated adds a soft shadow; setBorderVisible(false) for items inside other chrome), gradient-primary ThemedButton, PageHeader, SectionCard (elevated, optional withGlow() gradient wash),
SidebarButton (accent-tint selection), gradient HeroBanner, ThinProgressBar (gradient), GameCardArt (vivid gradient tile, hue from the game id, white icon or monogram). ui/TextCase.sentence turns ALL-CAPS titles into sentence case.
Cursors: ui/CursorArtwork has one silhouette per set (Classic, Quorix, Crystal, Ember, Neon reticle with a centre hotspot) and CursorManager.isGlow() for the optional halo.
Login gate (2026-10-05): ClientHandler.handle first checks PRE_LOGIN_TYPES (ping, login, sign-up, game list, update check/download); anything else from a connection that has not logged in gets notLoggedInResponse (typed refusal) and nothing reaches a handler. Per-address limits: GameServer.MAX_CONNECTIONS_PER_IP (200, env-adjustable) and ClientHandler.MAX_SIGNUPS_PER_ADDRESS_PER_HOUR (60); ChatManager.sanitizeFileName for shared files; ServerAccountStore.MIN_PASSWORD_LENGTH (8).
Windows app / launcher (2026-10-05): launcher/Launcher.java (built into QuorixLauncher.jar, outside both source trees) is what Quorix.exe runs: applies a staged client/QuorixClient.bin.new (validated, old kept as .old, rolls back if the new client won't load), then runs Quorix.main from client/QuorixClient.bin through a URLClassLoader. net/ServerAddress (client-only) reads server.txt / -Dquorix.server / the localhost:7777 default and points NetworkConfig at it before the first connection (Quorix.main). .github/workflows/windows-app.yml builds the zip + the server's Quorix.jar (see README). Update flow (2026-10-05): the launcher first calls net/PreStartUpdate.run (reflection, own socket, bounded) to stage a newer client, swaps it in (retrying renames up to 10 s), shows a small update window only if it takes >0.4 s, then starts the client; once running, net/ClientUpdateChecker (checkInBackground, checkNow, isUpdateStaged, staged listeners) handles the manual path and net/AppRestart restarts through the launcher (quorix.launcher property). UI: TopBar "Restart to update" button, SettingsPanel version row with "Check for updates".
Page kit (2026-10-01): every top-level page extends pages/PageScaffold (a PageHeader with title/subtitle/right-hand action, side
margins, a scroll body that tracks the viewport width so nothing adds a horizontal scrollbar; row helpers fullWidth/split (min 300px per
side)/columns over FitRows whose height follows their content; setBody for pages that scroll themselves) and is built from
pages/SectionCards (the old Home card, generalised: title, optional link, body, optional accent glow; HomeSectionPanel is now a thin subclass).
New shared pieces: ui/ThinProgressBar, ui/InitialBadge, ui/WrapLayout, pages/PickerItem; ThemedButton.getPreferredSize is never
narrower than its label. Rebuilt on it: Quests, Leaderboards, Achievements, Friends, Shop, Tournaments, Changelog, Suggest a Game, Settings,
Profile, Stats, Forums, Games, Moderation, Admin; Chat got a subtitle and wider sidebar. Dominion is untouched (deferred). The Login/Create
Account window paints one glow behind the whole window (AuthWindow) instead of per panel. pages/PinnedGames is the single pin list, backed by
economy.PinnedGamesStore (the Games page's Pin buttons and Home's pins are the same list).
Home (restructured 2026-09-30): pages/HomePanel = the scrolling ticker, then the six redesign sections, then Top Players
(the old Recently Played / Explore rows were removed 2026-10-01). The six are subclasses of pages/HomeSectionPanel (card with title + body, setBodyContent, refresh() called
on build and every 30s): HomeWelcomeSection, HomeContinueSection, HomeQuickPlaySection, HomeFriendsSection, HomeTournamentsSection
- all real as of 2026-10-01 (HomeWelcomeSection quests via CHALLENGES_REQUEST; HomeFriendsSection and HomeTournamentsSection
are NetworkManager.PushListeners that ask with sendAsync, the tournament list being a push-only type; HomeQuickPlaySection.pick() holds the
suggestion rule; HomeContinueSection renders HomeGameTiles - pinned first, from pages/PinnedGames, a per-computer Preferences list -
then recent games). HomePanel lays them out with fullWidth/split rows (FitRow: height follows content, so cards never stretch) inside a
WidthTrackingPanel scroll view (follows the viewport width; split cards keep a 300px minimum). GameDetailPanel has the "Pin to Home" button.
.cursor/prompts/home-redesign.md is the superseded Cursor brief.
Stats (added 2026-09-30): STATS_REQUEST/STATS_RESPONSE handled by ClientHandler.handleStats (public by name; own stats
need a login) from GameHistoryManager.getPlayCountsByGame and LeaderboardManager.getStatsRowsForAccount; new Message fields
statsTotalPlays/statsPlayCounts/statsGameRows/statsAchievementCount. Client: pages/StatsPanel (Pages.STATS, opened by
MainMenu.showStats(username), Back returns to the opener), the Stats card and real numbers in account/ProfilePanel, a "Full stats"
button in account/PlayerProfileDialog. NetworkManager.RESPONSE_TYPES is now guarded by net/ResponseTypesTest (see the tests section).
Changelog (added 2026-09-30, client-only): pages/ChangelogPanel renders the entries of the root CHANGELOG.md
(one hand-written file shared with website/app.py); pages/ChangelogParser parses it (## entry, - bullet,
two-space continuation, <!-- --> ignored). The file is bundled into QuorixClient.jar by build.sh/build.bat
and read from the classpath first, then the working directory / parent folder when run from source. Pages.CHANGELOG,
a sidebar entry and a NavIcons glyph.
Cursors (added 2026-09-30, client-only): ui/CursorArtwork draws the cursor sets in code (arrow, link, text per
set; CursorSet enum incl. SYSTEM and APP = follows the theme); ui/CursorManager (installed from Quorix.main)
applies the saved choice via a global AWT mouse listener that swaps Swing's default/hand/text cursors on whatever is
under the pointer and remembers originals so SYSTEM restores exactly; ui/CursorPicker is the Settings control.
Stored in Preferences under CursorManager's package node (per computer).
pages (MainMenu.java is the shell): a JFrame with Sidebar (west) + TopBar
(north) + a CardLayout content area (center) holding one panel per
pages/Pages.java key. MainMenu implements net/NavigationListener so Sidebar
reports nav clicks without knowing how paging works; page switches crossfade (snapshot
outgoing page, swap underneath, fade out over ~220ms) instead of snapping. One of
those keys, Pages.GAME_HOST, is special: it's the single slot an embedded game
occupies (see games/EmbeddedGamePanel.java), filled/cleared via
MainMenu.showGame(JComponent)/returnToGames() rather than being a fixed panel
registered up front like every other page. onNavigate(...) (the guard-checked path
Sidebar clicks go through) and the private switchToPage(...) (the actual card
swap, used internally by showGame/returnToGames too) are deliberately separate:
onNavigate asks the currently-hosted game's requestLeave() before leaving
GAME_HOST, but showGame/returnToGames skip that ask since by the time either
runs, leaving has already been decided one way or another - going through
onNavigate there would ask a second time.
Sidebar.java gates its Moderation entry via PermissionManager.isAtLeastModerator(...)
(a separate Admin entry was removed as redundant, since Role is hierarchical).
TopBar.java holds page title, live online-count, notification bell, account menu.
Page panels: HomePanel, GamesPanel (Home/All-Games tabs, pin toggles), ShopPanel,
LeaderboardPanel, AchievementsPanel, QuestsPanel, TournamentsPanel, ChatPanel,
FriendsPanel, GameSuggestionsPanel, AdminPanel, ModeratorPanel, SettingsPanel,
DominionPanel (Pages.DOMINION - Dominion's own persistent nav destination, a
Sidebar entry like Friends/Chat/Shop, deliberately not Pages.GAME_HOST; see the
dominion package section above for why). Its own content, not a match-game
window, so it's the one page panel with no QuorixServer counterpart to keep
byte-identical - the same client-only exemption every game's <Name>Window/
<Name>Dialog class already gets. Rebuilds its content wholesale from a fresh
DominionSnapshot on load/Refresh (same "clear and rebuild" shape
LeaderboardPanel.renderLeaderboard() already uses) rather than diffing - there's
no push-based live update for Dominion yet (no broadcast-on-tick mechanism, unlike
a real match), so staleness between an explicit Refresh is an accepted, honest V1
gap, not a bug.
Supporting: GlobalSearchField, NotificationBell/NotificationCenter,
QuickPlayDropdown, OfflineHubWindow (currently just Snake), WindowSizeMemory.
ui = generic theme-aware Swing widget toolkit, no page-specific logic:
RoundedPanel, ThemedButton/ThemedTextField/ThemedPasswordField/
ThemedTextArea/ThemedScrollBarUI/ToggleSwitch/StatusDot/StatusPill,
GameHubDialog (themed replacement for raw JOptionPane), DialogUtils,
PlaceholderPanel (a static helper: show(container, text) clears a container
and drops in one muted-text label for the "couldn't load"/"nothing here yet"
state every list-backed panel eventually needs; mutedLabel(text) alone for a
caller still mid-way through building a container's other children - the shared
version of what FriendsPanel/LeaderboardPanel/ShopPanel each hand-rolled
separately before this existed),
ChamferShape (angular cut-corner geometry - now used only by GameCardArt's
per-game icon glyphs, deliberately out of scope for the reskin below; no
longer used by any shared shell component), NavIcons,
GameModeCard/HeroBanner/PageHeader/MarqueeBanner/HoverGlowAnimator/
SidebarButton/SplashScreen/WinLineOverlay, and Party Mode's two purely
cosmetic effects: CursorTrailOverlay (attached once, whole-app, from
MainMenu - a global AWTEventListener on Toolkit, the correct way to
observe mouse motion regardless of which component actually received it,
rather than a listener on the frame itself which would only ever see events
landing on the frame and never on any child component) and ConfettiOverlay
(call burst(anchor) from any real "you won" moment - MemoryMatchWindow is
the first hookup, other games can adopt the same one-liner later). Both use
the same click-through trick (contains(x, y) always returns false on the
painted panel - the standard lightweight way to make a Swing overlay
non-interactive without manual mouse-event redispatching) and both are
governed by economy.PartyMode (off by default, a Preferences-backed
toggle in Settings, same pattern as PerformanceMode).
ScreenBreakOverlay (added 2026-09-29, per Bipin's explicit "no exceptions"
request) is the opposite kind of overlay - attached once, whole-app, from
MainMenu, but deliberately input-blocking, not click-through: it sets
itself as the frame's glass pane and attaches real (if empty) mouse/key
listeners specifically so events don't fall through. A single 1-second
javax.swing.Timer counts active screen time (paused while the window is
unfocused or minimized via MainMenu.isActive()/ICONIFIED) and, every 20
real minutes of it, shows a full-screen 30-second countdown that can't be
dismissed early - no close button, no Escape binding, no Settings toggle. If
MainMenu.isGameInProgress() (a new one-line method reading the existing
currentPageKey field - true while Pages.GAME_HOST is showing) is true
when the threshold hits, the break is deferred and rechecked once a second
until the game ends, rather than interrupting an active match. Client-only
by design - a wellness/UI feature, not shared game/account/economy logic, so
it's not part of the byte-identical sync list.
The "Aurora Glass" reskin moves the shared app shell - not any individual
game's own board/HUD screen - off the earlier flat, chamfered "Opera GX" look
toward softer rounded shapes with an ambient accent glow. Concept direction
was worked out as Gemini image-gen prompts, then as a real CSS/HTML design
canvas (a Claude Artifact, not checked into this repo) before being
translated into Swing code, so the Swing changes could be checked against an
actual rendered reference rather than eyeballed from a description.
Reskinned: UITheme (RADIUS_PANEL 14->18, RADIUS_BUTTON 10->14),
ThemedButton (primary buttons are now a rounded outline-in-accent-color over
a faint accent tint, with an ambient glow always present at rest and
brightening on hover, instead of a solid gradient-filled chamfered shape -
chamferedRect() was removed as dead code), ToggleSwitch (soft glow behind
the pill when on), DarkNavyTheme (richer near-black background, slightly
lighter/visible border), SidebarButton (the selected nav item is now an
inset glow ring around the whole pill instead of a solid accent bar down the
left edge), HeroBanner (rounded corners via RoundRectangle2D instead of
ChamferShape; the old full-width solid CTA bar flush against the bottom
edge is now a ThemedButton-style rounded outline-glow pill sized to its own
label, sitting under the title), TopBar (the bottom divider is now a plain
1px neutral border line instead of a solid accent-gradient bar),
GameModeCard (its per-mode color band is now custom-painted with rounded
top corners matching the card, instead of a square-cornered JPanel poking
out past the card's now-larger radius; the redundant, already-hidden
enableTopAccent() call was removed), and ProfilePanel's HeroCard (same
ChamferShape -> RoundRectangle2D swap as HeroBanner). GameHubDialog and
ShopPanel's cards needed no changes - both already built entirely from
RoundedPanel/ThemedButton, so they picked up the reskin automatically,
and their enableTopAccent() top stripes already correctly respect rounded
corners. Verified visually at each step via a throwaway Swing harness
rendering the changed components under Xvfb and capturing them with
Component.printAll() into a PNG - not just a clean compile. Every
individual game's own in-game screen is explicitly out of scope for this pass
(per the user) except the flagship game ("Quorix: Dominion," a persistent
nation-building strategy concept from the games backlog doc) once it's
actually built - it doesn't exist as a game yet.
theme = the color-system layer: Theme.java (interface: every color a screen might
need) + ThemeColor.java (color "roles" like BG_PANEL/TEXT_MUTED — components ask
for a role, never hardcode a Color) + ThemeManager.java (static holder for the
active theme + registry; listeners held as WeakReferences to avoid leaking game
windows that forget to unregister). Ten themes (DarkNavyTheme default,
CrimsonRedTheme, OceanTheme, MidnightPurpleTheme, SunsetOrangeTheme,
ToxicGreenTheme, IceBlueTheme, GoldRushTheme, CyberpunkPinkTheme,
BloodMoonTheme, GlitchTheme paired with GlitchEffectOverlay for its animated
distortion — the theme itself stays colors-only). UITheme.java = fonts/spacing,
theme-independent. GameColors.java = a separate, fixed palette for in-game
rendering that deliberately ignores the active Theme, so switching app theme doesn't
repaint the inside of Snake/Block Stack/etc. ThemeDropdown.java = the settings picker.
ui widgets consume theme (via ThemeManager.getColor(role)); pages panels are
built from ui widgets; NavIcons lives in ui, consumed by pages/Sidebar.
QuorixServerTests — the committed regression suite
A sibling to QuorixServer/ and QuorixClient/, not nested inside either -
build.sh globs every .java file under those two trees straight into the
shipped jars, so a test class living there would ship to every user's install.
Compiled against QuorixServer's freshly-built classes on the classpath and run
by test.sh (repo root, sibling to build.sh), which fails (nonzero exit) if any
test reports a failure. No external test framework (JUnit etc.) - matches this
project's zero-external-dependency philosophy (build.sh is plain javac + jar,
and both trees still open/run directly in BlueJ).
support/Check.java- the one shared harness class every test uses: acheck(label, condition)call per assertion, a runningchecks/failurescount, andfinish()as the last line ofmain()(prints the summary, callsSystem.exit(1)on any failure) - formalizes the exact hand-rolled pattern every prior scratch test already reinvented, not a new framework.net/QuorixSerializationFilterTest.java- a REALObjectOutputStream/ObjectInputStreamround trip throughQuorixSerializationFilter.FILTER(not just a compile check): aMessagecarrying anAccountand a fullDominionSnapshot(everydominion.*class the filter allow-lists, nested three deep through aList) deserializes correctly, and a deliberately non-allow-listedjava.util.HashMapis actually rejected with anInvalidClassException- proves the filter's deny-by-default"!*"tail really denies, not just that the config string reads that way.admin/GameSuggestionStoreTest.java/AdminLogTest.java/FeedbackManagerTest.java- each submits an adversarial payload (an embedded newline for the first two's one-line-per-entry format, an exact 64-dash delimiter line forFeedbackManager's block format) through the realsubmit()/log()API, forces a real save-then-reload round trip via a second fresh instance, and asserts the reloaded entries are byte-for-byte identical to the pre-reload list - the unambiguous proof nothing split into a second, forged entry. (An earlier version of the first two instead scanned for the absence of a"] admin:"-shaped substring, which false-failed against correct behavior - that exact forged-looking text is still legitimately present inline, as part of the one correctly-merged entry the fix produces, since the fix strips the newline rather than the attacker's characters. Caught and fixed before this landed, not shipped flaky.)chat/MatchChatRoomTest.java(2026-09-29) - the in-match chat rules the server must enforce: aLOCKEDroom (Telephone) delivers nothing until unlocked, non-members and empty/over-long text are handled, leaving/closing detaches everyone, a player joining a new match's room is moved out of their old one,closeAfterreally closes, and theMatchmakingKernelreally opens a room for its games (via Dice Duel).support/FakeClientHandler.javais the shared socket-lessClientHandlerthese andMatchmakingKernelAdoptersTestuse (moved out of that test's nested class). Mute and flood limits live inClientHandler.handleMatchChatand need real managers, so they are not covered here.forum/ForumServiceTest.javaandforum/ForumHandlerTest.java(2026-09-29). The first covers the store and rules: boards, title/body cleaning and length caps, ordering, lock/delete, the post and list caps, persistence and id continuity across a reload, damaged files, and the forgery round trip (a body containing record text stays ONE post). The second drives the realClientHandler(real account store, moderation manager and admin log) to pin the security rules: reading needs no login, posting does, a muted player and a flooder are refused, and delete/lock are moderator/admin only - a refused attempt leaves no admin-log entry, an accepted one does.dominion/DominionStoreTest.java- a full save/load round trip for every entity typeDominionStorepersists, including a declared-but-not-yet-active war whoseeffectiveFromTickmust survive exactly rather than being reinterpreted relative to whatever tick the reloaded world starts at, and loading with no file present yielding a fresh empty world rather than an error.games/MatchmakingKernelAdoptersTest.java(added alongside Dice Duel/Typing Duel'sMatchmakingKernelconversion, 2026-09-29) - added because it actually caught a real bug before it shipped: a first-pass conversion of both managers would have silently changed their matchId format (using the kernel's short constructor defaultsmatchIdPrefixtogameId, but each game's real format has no hyphen whileGAME_IDdoes), and this test asserts the exact prefix a real match-found message carries, not just that pairing happens at all - a correctness-critical regression this project would otherwise have no way to catch the next time this kernel gets a new adopter.economy/EconomyConfigTest.java(added 2026-09-29) - same "it actually caught a real bug" bar as the test above: Trivia Blitz had been silently paying its winner(s) zero coins since it shipped (getWinReward()'s table was missing a "trivia-blitz" entry the game's own code had always looked up - seeEconomyConfig.java's note andROADMAP.md's "Done" section). Asserts every one of the 24 game ids that actually callEconomyManager.awardWin(...)/EconomyConfig.getWinReward(...)gets a real, nonzero reward rather than silently falling through to the "unrecognized id" default - the exact category of bug this file's own zero-coins incident was.mechanics/ReconnectRegistryTest.java(2026-09-30) - the grace-period mechanism itself (timeout fires exactly once, a reconnect inside the window cancels it, nothing pending, cancel, a second grace period replaces the first, a refused reconnect) plusReconnectPolicy(30s, the exception list, guests, Chess enabled), using the package-visible short-grace constructor so nothing waits out 30 real seconds.mechanics/PairReconnectGamesTest.java(2026-09-30) - the sharedPairReconnecthelper as wired into Dice Duel, Signal Grid, Fusion Grid, Memory Match, Typing Duel, Card Rush, Air Hockey, Snake Arena and Block Stack Duel: one scenario per game through a small adapter (drop pauses and notifies, the other player's move is held, reconnect swaps the new handler in and reports game/slot/opponent/turn, timeout forfeits once, guest and deliberate Leave forfeit immediately, both-gone ends quietly). Real-time games check that ticks stop during the pause and stay stopped through the resume delay; Memory Match checks its mismatch timer refreshes the notice instead of faking a resume; Typing Duel checks a round that comes due while someone is away starts on their return.games/ChessReconnectTest.java(2026-09-30) - the same for Chess's hand-written version, plus a pending draw offer being cleared on a drop and a new login taking over a stale session.net/ResponseTypesTest.java(2026-09-30) - readsQuorixServer/net/ClientHandler.javafor every..._RESPONSEtype the server sets and requires each to be inNetworkManager.RESPONSE_TYPES(except the two deliberately-push tournament lists); a missing one makes a blockingsend()wait 10s while holding the global lock.test.shcompiles the client-onlyNetworkManager(with-sourcepath QuorixClient) for it and exportsQUORIX_REPO_ROOT.economy/StatsTest.java(2026-09-30) - play counts, rating/record/best-score rows and the realhandleStatshandler.account/AdminBootstrapTest.java(2026-09-30) - first-run admin setup (console, environment, bad input, no console).chat/CalcParserTest.java(2026-09-30) - the/calcparser.economy/PracticeRewardLimiterTest.java,economy/GamePlayedHandlerTest.java,games/CardRushHiddenInfoTest.java(2026-09-30) - the offline-reward rate limit (15s per game per account, 300 coins/day, resets next day), the realGAME_PLAYED_REQUESThandler (game ids that aren't[a-z0-9-]{1,40}are dropped; repeated claims pay once), and that Card Rush never sends a player the other's hand.- All five tests that touch a flat-file store hardcoding a relative file name
(
GameSuggestionStore/AdminLog/FeedbackManager/DominionStoreall do - same pattern asServerAccountStore) run from their own fresh temp working directory (test.sh's job, not the test classes' own) so they never touch this repo or collide with each other. - Deliberately a small first batch, not a mandate to graduate every future
scratch test - the reversible default from the 2026-09-28 testing-
infrastructure audit: the security/correctness-critical ones now, quick
one-off UI/visual checks stay scratch-and-discard. Whether every future test
should eventually graduate is still open - see
ROADMAP.md.
Regenerated periodically as the codebase evolves — see the repo's commit history for what's changed since this snapshot.
Dominion map (2026-10-11)
dominion/Province is an irregular region: id, x/y on a 1000x620 plane, terrain (incl. SEA), owner and an explicit neighbour list. dominion/MapGenerator builds the world once (seeded, ~90 land + 70 sea regions, Lloyd-relaxed, land kept connected); dominion/MapGeometry computes each region's drawable outline. Used by pages/DominionPanel (app) and ops/DominionView -> /dominion + /dominion/geometry (website SVG).