Felt Dojo: Architecture
Companion to the project brief. How the software is put together, how production is put together, and why each choice was made rather than the obvious alternative.
The shape of the thing, in one paragraph
A Spring Boot monolith owns all game state in memory, drives every live table on its own background thread, and pushes state to browsers over WebSocket. Postgres holds everything that has to survive a restart; the React frontend is a static bundle served directly by Caddy and never talks to Postgres. Production is one 2-vCPU virtual machine running three Docker containers behind a Caddy process that is not itself containerised. There is no message queue, no cache layer, no second node, and no orchestrator, and every one of those absences is a decision rather than an omission.
Part 1: Software
The core decision: a hand loop is a thread, not a state machine
Each live cash table and each tournament owns one background thread running a plain while loop: deal a hand, drive the betting, resolve, pause, repeat. When it's the human's turn, the loop blocks on a queue until the action arrives over the WebSocket.
The obvious alternative is an event-driven state machine: a table is a row, an action is an event, and a handler advances it. That's how you'd build this to scale horizontally, and it was rejected deliberately.
Why a thread wins here: poker is intensely sequential and the rules are full of "and then". A betting round that reads top to bottom as a loop with a blocking read is enormously easier to get correct than the same logic decomposed into a dozen handlers plus an explicit state field, and correctness is the whole product; a bug in the betting engine moves a player's chips. The thread's stack is the state machine, maintained by the language rather than by hand.
What it costs, stated plainly: state lives in one JVM's heap, so a second backend node is impossible without moving sessions out of process. That is the real scaling ceiling, and it caps concurrent users, not table size or field size.
A tournament drives all of its tables from that one thread, sequentially, which is affordable only because of the next decision.
Two-tier simulation: only the watched table is real
A 10,000-entrant tournament does not run 1,100 real tables. Only the table the human is sitting at plays full-fidelity poker; every other table is advanced by a simulator that posts the real blind level's antes and blinds, awards the pot, occasionally runs a confrontation, and conserves chips exactly.
The realism comes from using the real blind structure rather than an invented decay curve, which is what makes the field thin at a believable pace. Measured: 10,000 entrants, 1,162 hands, about six seconds end to end.
The seam that makes it work is one predicate, is a human watching this table, and it is deliberately the single gate for three separate things: whether a table plays real poker or gets simulated, whether state is broadcast at all this cycle, and how long the between-hands pause is. Keeping those three keyed off one check is what stops them drifting apart; when one of them was once missed, a reconnected client watched an entire abandoned tournament simulate to the finish in fast motion.
The same discipline governs broadcast size. The per-hand tournament payload carries only the human's own table's names plus summary stats, never the whole field. Full standings live behind their own paginated endpoint, fetched on demand rather than pushed every hand.
The bots, structurally
Three tiers behind one interface, differing in what range they believe they're facing rather than in threshold quality. The strategy layer returns a distribution over actions with frequencies, not a single action, built that way from the start so a fourth tier could be a data swap rather than a rewrite, and so the future analysis layer ("Review", to players) has an interface to compare a human's action against.
Two structural notes that matter more than they look:
- Advanced falls back to the tier below wherever its charts don't cover a spot, which preflop is exactly one cell. There used to be a second, much larger fallback: Advanced handed its entire postflop decision to Intermediate on tables no human was watching, and it was deleted on 2026-08-19 after an audit found no production path had ever triggered it. A tier that quietly becomes a different tier on a flag nothing sets buys less than it costs.
- Bot randomness is addressed by coordinates, not drawn from a stream. Which hand, which seat, which street, which action ordinal. A stream desynchronises the instant one player's line creates one extra decision, which would break duplicate poker's entire premise. The preflop equity table is a committed 7,605-entry resource rather than something each server computes for itself, because computing it per-process made it drift up to 2.4 equity points between restarts.
Persistence: what is durable and what is deliberately not
| where it lives | survives a restart | |
|---|---|---|
| Accounts, bankroll, hand history, stats | Postgres | yes |
| Tournaments | Postgres, snapshotted every 10 hand cycles | yes; they resume at boot |
| Duplicate matches and their action logs | Postgres | yes |
| Cash tables and the chips on them | Postgres (cash_games, V25), snapshotted between hands | yes; restored at boot as parked tables |
Cash tables were the honest gap until 2026-08-19, and now are not. For most of the project they lived only in memory: a shutdown hook cashed every live and parked table back to its bankroll on an orderly stop, which covered deploys, compose down and SIGTERM, but not kill -9, an OOM kill or the host dying. That got sharper when leaving a table started parking it rather than ending it, because a table then routinely outlived the session that created it.
cash_games (V25) now holds one row per table for as long as chips are on it: the hand loop snapshots at every between-hands boundary, create/rebuy/park write immediately, and a recovery service restores every surviving row at boot as a parked table with its stacks, names and button intact. The row is deleted when the table ends or the idle sweep cashes it out; a row means "chips are sitting on a table", and that is the only question the table exists to answer, which is why it keeps no finished rows the way tournaments does.
Deployed 2026-08-19. The shutdown hook that used to cash every table out on an orderly stop was removed in the same window, deliberately: persistence deleted its stated reason to exist, so a deploy no longer ends anyone's table because it comes back parked, from the same between-hands snapshot a crash would have left. The hourly idle sweep is now the only automatic settlement, and a test asserts the absence of any shutdown cash-out, because re-adding one would compile, pass everything else, and silently restore the old behaviour.
Two schema decisions worth knowing. Config objects are stored as JSON text columns, not normalised, which makes adding a field free at write time and a hazard at read time, because a row written before a field existed deserialises it as null. There is now a test that drops each field in turn and reads it back, rather than a convention that someone remembers. And hand_stats cascades from hand_history at the schema level, not in application code, specifically so that a retention or GDPR-deletion job written later cannot leave a strategic profile of a player behind after the hands themselves are gone. An application-level convention is exactly what such a job quietly skips.
The frontend, and why it is boring on purpose
React + TypeScript + Vite, built to a static bundle. It holds no game logic; it renders what the server broadcasts and sends actions back. The server decides everything, including things that could be client-side: the "surprise me" difficulty mix is computed on the server and never sent to the browser, because the point is concealment and a value the client holds is not concealed.
The one piece of real client-side machinery is a reveal queue that holds each street on screen for a fixed duration regardless of how fast messages arrive; without it, a fast server run-out flashes the whole board past. That queue is why the server's between-hands pause has to be long enough to drain it, and those two constants living in two languages with nothing binding them is a known, documented hazard.
Testing, and the two things it structurally cannot see
About 1,672 backend and 642 frontend tests, plus a real-Postgres CI job for the handful of cases where the database dialect is itself under test, and a bot-behaviour golden-file job that diffs a committed fingerprint byte for byte on every push.
The interesting part is what the suite is known not to cover, because both were learned expensively:
- A page can pass its entire suite and be unusable. Testing-library assertions check roles and text; a table with zero height still has every role and every string. Five accumulated one-line corrections were once dropped by forking a view, and the suite was green for all five.
- Two halves of a pipeline can each pass their own tests with nothing joining them. One feature shipped nine milestones with the write side and read side built separately, every test satisfied by its own half, and no data ever flowing between them. The rule that came out: for any A-writes / B-reads pair built in separate milestones, one test must run A for real and assert on what B actually returns.
Part 2: Production
The hardware
One Hetzner CX22: 2 vCPU, 4 GB RAM, 40 GB disk. That is the entire production estate.
It is deliberately small. The measured ceiling is far above the load: ramping on the production box found no functional limit up to about 150 concurrent players, and the configured cap is 40 by choice, not by limit, to leave headroom for tournaments, backups and deploys. Cash tables turn out to be pace-bound rather than CPU-bound: each needs roughly 0.035 hands per second, so a load average climbing under load is measuring threads queued, not players waiting.
The single most useful capacity lesson here was structural rather than numeric: the table admission cap and the hand-loop thread pool are two numbers that must agree, and for a while only one was configurable. Production ran 25 admitted against 12 that could actually run, and because the pool has an unbounded queue the extra tables weren't refused; they were queued behind threads that never free up. Sixteen players, twelve dealt, four sat at tables that never dealt a hand, with no error anywhere. The pool is now derived from the cap. If you add a "how many X" knob, derive the dependent one rather than documenting that they should match.
The topology
internet
│
[ Caddy ] ← native on the host, NOT in Docker
│ TLS, static files, reverse proxy, access log
┌──────────────┼───────────────┐
│ │ │
/assets/* /api/* everything else
/index.html /ws → SPA fallback to index.html
(static files /actuator/*
from disk) │
│ 127.0.0.1:8080
[ backend ] ── Docker
│
┌─────────┴─────────┐
│ │
[ postgres ] [ redis ] ── Docker, both bound to 127.0.0.1 only
Nothing but Caddy is reachable from the internet. Postgres, Redis and the backend all publish to 127.0.0.1 only, so the containers are not exposed even if the firewall were misconfigured.
Caddy runs natively rather than in a container, which is a deliberate asymmetry. It gets automatic TLS certificate management for free from the host, and keeping it outside compose means a backend deploy, which restarts containers, cannot take TLS termination down with it. The cost is that /etc/caddy/Caddyfile on the server and the example in this repo are two copies that can drift, which is documented in both places.
The frontend is built on the server and served from disk, not from a container. Caddy's try_files serves it as a real file before the SPA fallback ever runs.
The choices inside that picture
Docker Compose rather than Kubernetes. Three containers on one host. Kubernetes would add a control plane costing more RAM than the application uses, to orchestrate a system that cannot currently run on two nodes anyway (see the in-memory ceiling above). Compose is what a single-node deployment should be.
Postgres 16 in a container with a named volume, rather than a managed database. A managed instance is the right answer the moment there's a second node or an on-call rotation. At this size it is a recurring cost to replace a nightly pg_dump that already works and has a rehearsed restore path.
Migrations are Flyway, versioned in the repo (currently through V30), so the schema is a reviewable artifact rather than a state someone applied by hand.
Log rotation is configured explicitly, because Docker's default json-file driver never rotates. On a 40 GB disk, a backend logging a line per hand fills the disk given enough time, and a full disk is the worst kind of outage: Postgres stops accepting writes, the backend throws on every request, and the cause looks like an application bug rather than a housekeeping one. Three files × 10 MB per service.
Required environment variables use the ${VAR:?message} form, not plain ${VAR}. In docker-compose a plain ${VAR} for an unset variable is an empty string, not an error, so renaming a required variable boots the app misconfigured instead of refusing to start. That was found before it shipped, on a variable that would have sent every password-reset and invitation email with a link that had no origin: broken only for people receiving mail, nobody positioned to notice quickly.
Secrets never leave the server. docker/.env exists only in production and is gitignored; the JWT secret and the CORS allowlist have no defaults outside the local profile, so the app fails to start rather than booting insecure.
Static assets are cached aggressively and index.html is not. Serving static files with no cache headers is not "uncached": browsers fall back to heuristic caching and keep returning visitors on the previous build indefinitely. Every frontend fix reached new visitors and nobody else, silently, for days. Content-hashed assets now get a year and immutable; everything else gets no-cache, which means revalidate, not don't store: a ~100-byte 304 with the ETag already present, so the fix costs less traffic than having none.
Redis is running and the application does not use it
Stated plainly because it is true and mildly embarrassing: a Redis container runs in production, the backend's startup is gated on its healthcheck, and connection settings are passed to it, but spring-data-redis is not a dependency and no code touches it. Rate limiting, which is the obvious thing it would do, is a deliberate in-process limiter instead.
It is there because the original architecture anticipated needing it, and nothing has removed it. The cost is memory on a 4 GB box and a startup dependency that can fail. Decided 2026-08-19: remove it. The removal is scheduled as its own small deploy rather than folded into a feature one.
Operations
- Nightly
pg_dumpat 03:17, rotated locally at 14 days, synced off-site to Backblaze B2 at 03:57 via rclone. Both scripts email on failure. The restore path is rehearsed, not assumed. - UptimeRobot hits
/actuator/healthevery five minutes with email alerts, external monitoring, because a box that is down cannot report that it is down. - Sentry, two projects, backend and frontend on separate DSNs. Deliberately no session replay: it would ship a recording of a player's table, hole cards included, to a third party. Both halves are inert with the DSN unset, which is verified rather than assumed: with no DSN the frontend bundle contains no Sentry code at all. The blind spot to know about: it reports what is logged at ERROR, so an exception that is caught and swallowed is invisible to it. That is exactly the shape of the components most likely to need it, which is why those catch blocks are required to log at ERROR: the difference between a crashed component and a quiet one.
- Caddy access logging is on, and it is the one analytics input that cannot be backfilled: the backend only ever sees
/api/*, so a visitor who reads the landing page and leaves is invisible to every other number. It records client IPs, which is personal data, so its retention window and the privacy policy are a pair that has to move together. - Deploys are a script, run only by the owner. It builds the frontend on the server, rebuilds the backend image, and restarts. There is no blue/green and no zero-downtime story: a deploy drops every WebSocket. That is survivable rather than good: cash tables come back parked from their persisted snapshots, tournaments recover at boot, duplicate runs park, and nobody is signed out. Since 2026-08-20 a maintenance-window warning runs ahead of it: the deploy script announces a countdown to every connected player before the restart (currently a short 60-second lead while the site is quiet; the public announcement restores the full five minutes), and the admin panel can announce a window without deploying at all.
The honest limits of this design
1. One node, because sessions are in one heap. This is the real ceiling. Fixing it means moving session state out of process before a second backend is possible. 2. ~~Cash tables don't survive a hard crash.~~ Closed: built 2026-08-19 and deployed the same day; see the persistence section. 3. One thread per tournament, so a large field where many tables are visible would serialise. Not a limit today, because only the human's table is real. Parallelising it requires fixing a shared deck object first; safe today only because tables are dealt strictly one at a time. 4. A deploy is still a hard cut, now with a warning. Every socket drops, but since 2026-08-20 a countdown is announced first, and nothing a player cares about is lost: tables come back parked. 5. CI runs against H2 by default, so any bug specific to the Postgres dialect is structurally invisible to it. That is why the real-Postgres job exists: for the handful of tests where the dialect is the thing under test. It caught a production outage of the admin panel where a nullable JPQL parameter had its type inferred as bytea and every unfiltered query threw.
What would change, and when
| trigger | change |
|---|---|
| Concurrent users approaching the measured ceiling | Session state out of process, then a second backend node behind Caddy |
| ~~Cash tables carrying anything a player would mind losing~~ | ~~Persist and recover them~~, done, 2026-08-19 |
| Real money, ever | A managed database, an on-call rotation, and a security review with a scope wider than the 2026-08 one |
| Anyone else joining the project | Staging environment first: there isn't one, and one person deploying carefully is the only reason that has been survivable |