Online client and game server for Vampire: The Eternal Struggle. Java WAR deployed on Tomcat 9. No database — all state is persisted as JSON (and, for a shrinking set of legacy games, XML) files under a JOL_DATA directory.
- Java 21 (Corretto recommended — Lombok's annotation processing breaks on newer JDKs)
- Maven 3+
Install Java 21 as per your operating system.
./mvnw clean package (macOS/Linux) or mvnw.cmd clean package (Windows)
JOL_DATA( required ) - points to the game data directory, example data is available atsrc/test/resources/data. Can also be set via thejol.datasystem property, which takes precedence.ENABLE_TEST_MODE- disables scheduled persistence; set automatically by@SetEnvironmentVariablein tests, default:falseENABLE_CAPTCHA- disable Cloudflare Turnstile for local development if required, default:trueJOL_RECAPTCHA_KEY/JOL_RECAPTCHA_SECRET- Cloudflare Turnstile site key / secret key, required when captcha is enabled (named "recaptcha" for historical reasons — this is Turnstile, not Google reCAPTCHA)DISCORD_BOT_TOKEN/DISCORD_PING_CHANNEL_ID/DISCORD_API_VERSION- required for Discord integration - bot token, channel id to post pings, and Discord API version (e.g.10); there is no code-level default for any of the threeVAPID_PUBLIC_KEY- required for web push notifications - VAPID public key embedded client-side innotification.jsp. The matching private key is not an env var —NotificationServicereads it from<JOL_DATA>/vapid_private.pemBASE_URL/KEY_ID/KEY_FILE- required for CloudFront-signed secured card image URLs - distribution base URL, key pair id, and path to the CloudFront private key file, used bySecuredCardLoaderTYPE- visual/environment label (dev,test,prod, ...), default:dev- also selects whichcss/<TYPE>.cssstylesheet is loaded
Run the tomcat9:run goal after setting up the environment variables you need:
JOL_DATA=src/test/resources/data ./mvnw tomcat9:run
The app is served at http://localhost:8080/jol.
./mvnw test runs the full suite (Cucumber BDD scenarios included) except the Builder-tagged group, which regenerates the static card JSON/HTML used by the nginx static server and isn't part of normal test runs.
./mvnw test -Dtest=DoCommandTest runs a single test class.
Everything below lives under JOL_DATA.
games.json- hashmap of Game Name ->GameInfo.javapastGames.json- hashmap of Timestamp ->GameHistory.javaplayers.json- hashmap of Player Name ->PlayerInfo.javachats.json- list of global chat entriesdecks.json- hashmap of Player Name -> Deck Name ->DeckInfo.javaregistrations.json- hashmap of Game Name -> Player Name ->RegistrationStatus.javatournaments.json- hashmap of tournament data; per-tournament detail lives undertournaments/<uuid>/game-timestamps.json/player-timestamps.json- last-activity tracking, per game and per playersite-notes.md- admin-editable site banner/notes shown to playerssubscriptions.json- web push subscription recordsjwt_secret.key- HMAC signing key for login access tokens, generated on first boot and reused thereafter — losing it forces every active access token to be re-issued via its refresh token (or a fresh login if that's also gone)refreshTokens.json- "remember me" refresh tokens, one row per logged-in device, keyed by player namevapid_private.pem- private half of the web push VAPID key pair (seeVAPID_PUBLIC_KEYabove)decks/-*.json, modern-format decks, ULID-namedgames/<uuid>/-game.json(state) andhistory.json(chat/action history) for current games, plus one<deckId>.jsonper registered deck. A small number of not-yet-migrated legacy games still carrygame.xml/actions.xmlinstead (seeGameDataConversion)
Card data (crypt/library text, images) is not read from JOL_DATA at runtime — CardService fetches a pre-built cards.json from CloudFront (static.deckserver.net), falling back to a local static/secured/cards.json if that's unreachable. That file is generated from VEKN's official vtescrypt.csv/vteslib.csv by the test-scope CardDatabaseBuilder (the Builder-tagged tests).
No database anywhere — every durable piece of state above is a file under JOL_DATA, and the pattern is the same for all of them:
- Each
*Servicesingleton (PlayerService,GameService,RefreshTokenService, ...) extendsPersistedService, which loads its JSON file into an in-memoryMapexactly once, in its private constructor. - All reads and mutations happen against that in-memory map — there's no per-request disk I/O on the hot path;
PlayerService.get(name)is aHashMaplookup, not a file read. - Persistence is write-behind, not synchronous:
PersistedServiceschedules apersist()flush on a fixed interval per service (PlayerService/GameServiceevery 5 minutes,TournamentServiceevery 10, etc.) via a daemonScheduledExecutorService, backed by aCleaner-registered fallback and an explicit flush fromJolApplicationInitializer.contextDestroyed()on graceful shutdown.ENABLE_TEST_MODE=trueskips scheduled persistence entirely so tests don't fight the filesystem. - Consequence: a hard crash (as opposed to a graceful shutdown) can lose up to one persistence interval's worth of writes — there's no write-ahead log or journal. This trades durability guarantees for operational simplicity, which is a reasonable call for a game server where the worst case is replaying a turn, not losing money.
Games are the one exception with real substructure, since a game is too much state for a single flat map entry: games/<uuid>/game.json holds the live GameData, history.json holds the chat/action log, and each registered deck gets its own <deckId>.json alongside them. GameService still follows the same load-into-memory-then-persist-on-schedule pattern underneath, just keyed by directory instead of by map entry.
Legacy XML is a shrinking remnant, not the design: a small number of pre-migration games were never converted from the old game.xml/actions.xml (JaxB) format to JSON. GameDataConversion (net.deckserver.jobs) reads those on the fly; all new games are JSON-only, and this code path should be treated as obsolete-in-progress rather than something to build new features against.
Cards are the other exception — crypt/library data isn't in JOL_DATA at all (see above); keeping it out of the per-instance data directory means it stays identical across every deployment without needing to be synced.
- JSP / Servlets - page entry points and login/session bootstrapping
MainServlet.java- maps to/,/main.jsp,/lobby,/deck,/admin,/game/*,/tournament,/tournamentAdmin,/profile,/active,/watch,/superLoginServlet.java- maps to/loginLogoutServlet.java- maps to/logoutRegisterServlet.java- maps to/register- JSP templates - located under
/WEB-INF/jsps, grouped by function (lobby,game,decks,admin,tournament,tournament-admin,profile,watch,help,main); each has its ownlayout.jsploaded first, then subsequent fragments /WEB-INF/main.jspholds the references to the functional layout sections
- Jersey - JAX-RS implementation for the REST API under
/jol/api/..., which fully replaces the DWR-based client the app used to run onJolApplicationconfigures the application path and package scanning (net.deckserver.rest)- Resources:
PageResource(navigate/poll/chat),LobbyResource,DeckResource,GameActionResource,GameResource,UserResource,PlayerResource,AdminResource,TournamentResource,NotificationResource,AuthResource,SystemResource SecurityFilterrejects unauthenticated calls with 401 and populates the JAX-RSSecurityContext(Principal/isUserInRole)src/main/webapp/js/ds.jsis the hand-written fetch-based REST client consumed by the JSPs and game JS — see Frontend Architecture below for how it loads and updates pages
- WebSocket (JSR-356) - lightweight server push over
/ws/updatesJolWebSocketEndpointauthenticates the handshake off the login cookie and handles join/leave/ping framesWebSocketRegistrytracks player -> socket(s) and game -> sockets mappings; clients re-poll the REST API on receipt rather than getting full payloads over the socket
- Authentication - cookie-based, no server-side session store; see Authentication below for the full request flow
- Jackson - JSON serialization library, used throughout for reading/writing all data files listed above
- JaxB - legacy XML serialization, retained only to read pre-migration
game.xml/actions.xmlfiles (net.deckserver.game.jaxb,GameDataConversion); all new games are stored as JSON - jBCrypt - password hashing for
PlayerInfo.hash - Caffeine - in-memory caching, e.g. active-user summaries and hot
JolGamelookups - jQuery - DOM manipulation and event wiring in
ds.jsand the page JS - Nginx - static file server hosted at
static.deckserver.netCardDatabaseBuilder.java(test-scope) parses the VEKN-supplied crypt/library CSVs and generates the static JSON/HTML consumed by tooltips and modals, plus card images
- Docker - runs the application server on deckserver.net, fronted by Traefik for TLS termination and routing
docker-compose.yml- production config (prod,test, andstaticnginx containers), each with its own persistent named volume forJOL_DATAlocal-docker-compose.yml- local config for the static data and/or application server
There is no client-side router or virtual DOM — the browser loads one real page (main.jsp, which contains every panel: lobby, game, decks, admin, etc., toggled by CSS), and after that everything is driven by polling/pushed JSON that gets stitched into the existing DOM by jQuery. The whole cycle lives in src/main/webapp/js/ds.js.
-
Initial load — on
$(document).ready,ds.jsreads the current path (/jol/game/<id>, etc.) to figure out which view to start on, then callsDS.init(target). This posts toPOST /jol/api/navigatewith{target, init: true}and, on response, callsinit(data), which applies the saved dark-mode preference, hands the payload toprocessData, and opens the WebSocket (initWebSocket()). -
processDatais the entire dispatch mechanism. Every response from/navigate,/poll, or any action call (DS.submitForm,DS.createGame, ...) is a flat JSON object whose keys are function names:const processDataHandlers = { checkVersion, navigate, loadGame, callbackMain, callbackLobby, callbackShowDecks, callbackAdmin, callbackProfile, callbackTournament, callbackTournamentAdmin, callbackAllGames, showStatus, setPreferences, setEdgeColorPref, }; function processData(a) { for (const key in a) { processDataHandlers[key]?.(a[key]); } }
processDataloops over the object's keys and calls the matching handler with that key's value — the server response shape is the client dispatch contract. Key names are produced server-side byViewCreator.getFunction()(e.g.GameCreator.getFunction()returns"loadGame",MainCreatorreturns"callbackMain") and consumed client-side by a same-named function inds.js. Adding a new panel means adding both aViewCreatoron the server and a matching function here — nothing enforces the pairing beyond the string matching, so a rename on one side silently breaks the other. -
Navigating. Clicking a nav link calls
DS.navigate(target)→POST /navigatewith just{target}(noinit).PageResource.navigatesets the player's current view (PlayerModel.setView/enterGame) server-side, then returns a freshUpdateFactory.getUpdate()payload — same shape as the initial load, so it flows throughprocessDataidentically. Browser back/forward (popstate) re-issues the same navigate call rather than doing any client-side history diffing. -
Staying current: WebSocket-first, polling fallback.
/ws/updatespushes lightweight signals only —{"type":"game","id":...}or{"type":"main"}— never the actual payload. On receipt, the client re-fetches:DS.getState(game, false)if it's a game update for the game currently open, orDS.doPoll()(→GET /poll) otherwise. If the socket drops,ds.jsfalls back to a 5-secondsetTimeoutpolling loop (errorhandler) until it reconnects, and avisibilitychangelistener forces a reconnect plus one catch-up round-trip whenever a backgrounded tab becomes visible again (sockets can die silently while backgrounded, with noonclosefiring until the OS actually tears the connection down). -
Rendering a game specifically.
loadGame(data)(theGameBeanpayload — see Game State Model below) doesn't re-render the whole board on every update.data.stateanddata.handare pre-rendered HTML fragments (JSP-rendered server-side byGameView), swapped into the DOM wholesale only when the server says something actually changed. Chat lines (data.turn) get appended incrementally rather than replacing the log. -
Errors and auth expiry. Any 401 that survives
ds.js's own silent-refresh retry (see Authentication below) redirects straight to/jol/login(errorhandler). Any other failure shows a "reconnecting" banner and retries via polling.
Mental model for a new dev: the frontend has no state store of its own beyond a handful of module-level lets (game, player, currentPage, cached lobby/profile data for re-rendering). The server is the single source of truth; the client's job is "ask for an update, dump whatever comes back into the DOM via the function named by that key." This is simple to reason about and has no build step, but every UI change touches both a Java ViewCreator/bean and the matching jQuery handler by hand.
Cookie-based, with no server-side session store — replaces an earlier HttpSession-based design that didn't survive app restarts or support "remember me."
Two cookies, two lifetimes:
jol_at(access token) — a signed JWT, 15 minute expiry,HttpOnly+Secure. This is the only thing any request actually trusts:SecurityFilter(REST),MainServlet(JSP pages), and the WebSocket handshake (JolWebSocketEndpoint.Configurator) all validate it directly by signature, with no lookup required. That's what makes auth restart-proof — the signing key (AuthService, persisted to<JOL_DATA>/jwt_secret.keyon first boot) is the only piece of state that has to survive, not a live session table.jol_rt(refresh token) — an opaque, long-lived, per-device token,HttpOnly+Secure, tracked server-side byRefreshTokenService(<JOL_DATA>/refreshTokens.json). Used only to silently mint a newjol_atonce it expires — the user never sees a re-login prompt as long asjol_rtis still valid.
Token shape and rotation. A jol_rt cookie value is {id}.{secret} — the id gives O(1) lookup of the stored row, the secret is what's hashed (SHA-256) and compared. On every successful use, the secret half is rotated (new random secret, same id, new hash stored) and the token's expiry slides forward — up to a 90-day absolute cap from original creation, regardless of how often it's refreshed. If a presented secret doesn't match the currently-stored hash for its id, that means an already-rotated-away token was replayed (a strong signal of token theft), so the row is revoked outright rather than silently rejected.
Multi-device. Each login (or "remember me" checkbox) creates its own RefreshTokenService row, tagged with a truncated User-Agent as a device label. Logging out revokes only that row (AuthService.clearAuth); other devices are untouched. UserResource exposes /user/devices (list), /user/devices/{id} (revoke one), and /user/logout-all (revoke everything) for the profile page.
Request flow. AuthService.authenticate(req, resp) is the single entry point every auth check goes through:
- Is
jol_atpresent and does it verify? → done, return the username, no I/O. - Otherwise, is
jol_rtpresent and does it validate againstRefreshTokenService? → mint a newjol_at, rotatejol_rt, set both cookies on the response, return the username. This is what makes an expired access token invisible to the user on a normal page load or REST call. - Otherwise → unauthenticated (401 for REST, redirect-to-login for JSP pages, handshake rejection for WebSocket).
Because step 2 needs to write response cookies, it only happens where a response is available (REST filter, JSP servlets). The WebSocket handshake only does step 1 — if jol_at has already expired by the time a socket connects, the connection is rejected outright rather than silently refreshed; in practice this rarely bites because a fresh page load always mints a new jol_at first.
Client side (ds.js). apiCall retries exactly once on a 401: it calls POST /jol/api/auth/refresh (AuthResource, deliberately exempted from SecurityFilter's auth check since its whole purpose is to run after auth has expired), and if that succeeds, replays the original request. This is what lets a long-idle tab silently recover without the user noticing, as long as jol_rt hasn't also expired.
Notable non-goal: logging out doesn't retroactively invalidate an already-issued jol_at — it only stops that device from getting a new one. A stolen/leaked access token remains valid for up to its remaining 15-minute window even after logout. This is the standard access/refresh-token trade-off, not an oversight.
Password storage is unrelated to the above and unchanged: PlayerService.authenticate checks a jBCrypt hash (PlayerInfo.hash, cost factor 13) — no tokens or cookies involved in verifying the password itself, only in what happens after it succeeds.
This is the piece most likely to confuse a new developer, because there are four similarly-named classes involved and the caching/dirty-tracking is easy to miss on a first read.
-
PlayerModel(net.deckserver.dwr.model.PlayerModel) is the player's piece of session-equivalent state — one instance per logged-in player, held inJolAdmin.pmap(Map<String, PlayerModel>, created lazily byJolAdmin.getPlayerModel). It knows almost nothing about game internals: just whichviewthe player is currently on ("main","game","lobby", ...) and, ifview == "game", whichgame(by name) they're looking at (enterGame/getCurrentGame). It also carries a few other player-scoped UI bits — the currently-loaded deck for the deck editor, the global chat read-position. -
GameModel(net.deckserver.dwr.model.GameModel) is the game's piece of state — one instance per active game, held inJolAdmin.gmap, wrapping the actualJolGame/GameData. It owns aMap<String player, GameView> views— oneGameViewper player currently seated at or watching that game, created lazily ingetView(player). All game mutation (submit,endTurn, notes updates, ...) happens here, and after each mutationGameModelwalks itsviewsmap and flips dirty flags on everyGameView(doReload/reloadNotes) so every connected player's next fetch knows what to re-render. -
GameView(net.deckserver.dwr.model.GameView) is the per-player, per-game rendering cache and dirty-tracker — this is the piece that tiesPlayerModeltoGameModel. It's not stored onPlayerModelitself;PlayerModelonly remembers the game's name. Every fetch resolves it fresh:GameCreator.createData(player)readsplayer.getCurrentGame(), looks up theGameModelviaJolAdmin.getGameModel(name), and callsgame.getView(player.getPlayerName())— which returns the same cachedGameViewinstance across requests (keyed by player name inGameModel.views), or creates one on first access.GameViewtracks five independent dirty flags (stateChanged,phaseChanged,turnChanged,globalNotesChanged,privateNotesChanged) plus per-region collapse/expand UI state (collapsed) and a local buffer of unsent chat lines (chats). -
GameBean(net.deckserver.dwr.bean.GameBean) is the flat, immutable JSON DTO actually sent to the browser — built fresh on every call byGameView.create(). This is where the dirty flags pay off:create()only does the expensive work (renderinghand.jsp/state.jspto a string viaJspRenderer, which requires aRequestContext) for fields whose flag is currently set — e.g.handstaysnullunlessstateChangedis true, so an update that only added a chat line doesn't re-render the whole board HTML. After building the bean,clearAccess()resets all the flags tofalseso the next call is a no-op unless something changes them again in between.
Full round trip for one game action, tying it back to Frontend Architecture above:
- Client:
DS.submitForm(game, phase, command, chat, ping)→POST /jol/api/game/{name}/submit. GameActionResource→GameModel.submit(player, ...)runs the command (DoCommand), mutatesJolGame, then callsdoReload(...)to mark every seated player'sGameViewdirty.- The response to the acting player is built synchronously via the normal
update()→UpdateFactory.getUpdate()→GameCreator→GameView.create()path, same as any other request, riding back on the HTTP response for that call. - Other players watching the same game don't get this HTTP response — they get a WebSocket
{"type":"game","id":...}ping, which (per Frontend Architecture) triggers their client to callDS.getState(game, false), which re-enters the exact sameGameCreator/GameView.create()path from their own request, reading whichever dirty flagsdoReloadset for theirGameViewinstance.
So: one game mutation, one JolGame update, but N independently dirty-tracked GameViews (one per connected viewer) and N separately-triggered fetches to collect them — GameModel is the fan-out point, GameView is what makes each fan-out target's re-render minimal.