Felt Dojo

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:

Persistence: what is durable and what is deliberately not

where it livessurvives a restart
Accounts, bankroll, hand history, statsPostgresyes
TournamentsPostgres, snapshotted every 10 hand cyclesyes; they resume at boot
Duplicate matches and their action logsPostgresyes
Cash tables and the chips on themPostgres (cash_games, V25), snapshotted between handsyes; 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:


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

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

triggerchange
Concurrent users approaching the measured ceilingSession 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, everA managed database, an on-call rotation, and a security review with a scope wider than the 2026-08 one
Anyone else joining the projectStaging environment first: there isn't one, and one person deploying carefully is the only reason that has been survivable