Architected for a million.
Measured at fifty thousand.
Measured
50,000
concurrent players
10,000 tables in one run, chips exact, zero errors
Architected for
1,000,000
registered players per liquidity pool
sized for 50,000 concurrent and 10,000 tables per pool, with 5× headroom
Measured
46%
fewer bytes per player
and 75% fewer messages with protocol 1.1, old and new clients at the same table
Measured
1 hand
voided when a silo is killed mid‑hand
refunded exactly once; a drained silo voids none
The actor model
One actor per table.
One per player, lobby and tournament.
An actor is a small, single-threaded object with its own state. Nothing shares a lock with anything else, so a hand at one table never waits on a hand at another — and when a silo joins or leaves, the cluster moves the actors, not the code. The game logic never knows how many machines it runs on.
- Gateways scale on connections, silos on tables, the staff tier on its own — each a separate Kubernetes workload.
- The gateway and the game tier co-host in one process, so a complete poker room runs on a single node.
RoomGrain
one per table
Deals the hand, runs the clock, holds the seats and sends each burst of events to the gateways that need it.
UserSessionGrain
one per player
Knows where the player is — which gateway, which tables — and answers the gateway that asks on a reconnect.
LobbyGrain
one per tenant
Holds every table summary for the tenant; rooms write through to it, and it to the cache the gateways read.
TournamentGrain
one per tournament
Registration, the blind clock, balancing and payouts for one event.
SchedulerGrain
one per tenant
Keeps the Sit & Go lines open — when one fills, the next opens — and makes scheduled tournaments ahead of time.
GatewayGrain
sixteen per gateway node
The delivery mailboxes of one gateway, sharded so no single actor sits on the path of every event.
The grains in platform/Poker.Platform.Grains, on Orleans 9.
Routed delivery
No backplane.
Every event goes where it is needed.
/// One gateway node. Rooms push each
/// event only to the nodes that actually hold a subscriber,
/// instead of every message crossing a global backplane —
/// which is what caps the Redis design in the tens of
/// thousands of messages per second.
[Orleans.CodeGeneration.Version(3)]
public interface IGatewayGrain : IGrainWithStringKey
{
/// Subscribes an observer that also understands
/// ReceivePrepared — and, with it, batches — so rooms
/// send it bytes ready for the socket.
Task SubscribePrepared(IGatewayObserver observer);
/// The node's live observers, so a room can deliver to
/// them directly instead of through this grain.
Task<IReadOnlyList<GatewayObserverInfo>> GetObservers();
/// Delivers to every observer this node has registered.
/// Never throws at the caller.
Task Deliver(GatewayEnvelope envelope);
}
Measured
53 / 0
events at the watching gateway / the other
a table's events reach only the gateway that holds its viewers
Less work per hand
Each event is serialised once and crosses the cluster as socket-ready bytes; a hand settles in a fixed eight database round trips.
Autoscaling
Each tier scales
on its own signal.
A gateway is cheap per connection and finite in how many it can hold, so CPU would scale it far too late. It scales on the number it actually runs out of: open connections. The game tier holds live tables, so it grows when an operator says so — and every pod spreads across zones, so losing a zone costs a share of the tables, not all of them.
Measured
2 → 4
gateways, scaled on connections
through the HPA and through KEDA, and back to 2 when the load left
A lobby that never reaches the game tier
The lobby is a read model in Valkey, coalesced every 250 ms; when the cache cannot answer, the lobby grain does.
| Tier | Scales on |
|---|---|
| Gateways | Live connections poker_gateway_connections, 8,000 per pod, 2 to 20 pods — the HPA or KEDA |
| Game tier | Tables Silos added deliberately: a new silo rebalances live tables, so it is a decision, not a reflex |
| Staff tier | Staff The admin API runs as its own workload, never on the players' path |
| Lobby | Browsing players Served from the Valkey read model, so a browsing crowd never reaches the game tier |
autoscaling:
enabled: true
provider: hpa # or keda, for a managed Prometheus
minReplicas: 2
maxReplicas: 20
targetConnectionsPerPod: 8000
Protocol 1.1
Fewer, larger messages.
The same events, a quarter of the messages.
Measured
46%
fewer bytes per player
and 75% fewer messages with protocol 1.1, old and new clients at the same table
Measured
75%
fewer messages per player
29 instead of 114 per player per hand, 22.7 KB instead of 42.3 KB, the same events
Measured
−44%
gateway CPU at 25,000 players
4.9 cores on protocol 1.1, against 8.6–8.8 on 1.0
| Received | 1.0 | 1.1 | Change |
|---|---|---|---|
| Bytes received | 42.3 KB | 22.7 KB | −46% |
| Messages received | 114 | 29 | −75% |
| Events received | 104 | 100 | the same content |
| Private YourTurn messages | 4.3 | 0 | built by the client |
| Bytes sent | 1.08 KB | 1.07 KB | unchanged |
Measured on 26 September 2026 — 40 tables × 5 players, every frame captured.
/hubs/table?access_token=…&X-Poker-Protocol=1.1
interface RoomEventBatch {
// every event in the batch is this room's
roomId: string
// server time the burst was produced, Unix ms
serverTimeMs: number
events: RoomEventItem[] // in order
}
interface RoomEventItem {
// the room sequence number, exactly as 1.0 numbers it
seq: number
handId?: string | null // only on HandStarted
payload: RoomEventPayload
}
Failover and resync
Lose a server, lose one hand.
Drain a server, lose nothing.
Measured
1 hand
voided when a silo is killed mid‑hand
refunded exactly once; a drained silo voids none
A silo is lost mid-hand
- 1Its tables reactivate on the silos that remain.
- 2Each room voids the hand it was dealing when it comes back — that hand, and no other.
- 3Every chip committed to it is refunded exactly once; nothing settles twice.
- 4Players resync from a snapshot and the next hand deals.
A silo is drained for a deploy
- 1Readiness turns to 503 and the node leaves rotation.
- 2No new hand starts on it.
- 3Hands in progress play out, for up to 120 seconds.
- 4Kubernetes stops the pod only after the drain. No hand is voided.
A connection drops
- 1Every event carries its room's sequence number.
- 2A gap means a missed event: the client asks for a snapshot.
- 3After a reconnect, through any gateway, it rejoins every table it had open.
- 4The snapshot carries the player's pending turn, so a decision is never lost.
Measured
Fifty thousand players.
On ten thousand tables.
The shape of the run
- 8 silos · 6 gateways · 8 load generators
- 50,000 connections · 10,000 tables × 5 seats
- 2.5 s to think · 2 s between hands · protocol 1.1
- 120 s warm-up · 180 s measured
- One 16-core / 32-thread machine, generators included
Measured
232
hands per second
against a 200 hands/s target, 3,816 actions/s
Measured
85 ms
p99 action‑to‑broadcast
median 8 ms, against a 250 ms budget
Measured
132 ms
p99 acknowledgement to the acting player
249 ms in the second run, against 676–1,331 ms before Releases A–C
Measured
0
chip‑conservation violations
every hand, every run — checked after every state transition
Before and after, at 50,000 players
- Before — 26 Sep, two runs
- After Releases A–C — 27 Sep, two runs
Action to broadcast, median
38–45 ms
8 ms
Action to broadcast, p99
205–270 ms
85–131 ms
250 ms budget
Acknowledgement, p99
676–1,331 ms
132–249 ms
Silo cores per 100 hands/s
5.95–6.08 cores
3.64–3.84 cores
Memory per silo
1,094–1,181 MiB
399–402 MiB
Seating 50,000 players, stage length
1,240–1,315 s
682–719 s
Measured
−40%
silo CPU per hand
3.6–3.8 cores per 100 hands/s, against 6.0 on the build before
Measured
~400 MiB
memory per silo at 50,000 players
about a third of the 1.1 GiB the build before held
Measured
11–12 min
to seat 50,000 players
half the 21–22 minutes the build before took
Every number from the four runs
| Metric | 26 Sep · run 1 | 26 Sep · run 2 | 27 Sep · run 1 | 27 Sep · run 2 |
|---|---|---|---|---|
| Connections | 50,000 | 50,000 | 50,000 | 50,000 |
| Hands per second | 229.8 | 227.9 | 232.0 | 231.7 |
| Actions per second | 3,730 | 3,661 | 3,816 | 3,810 |
| Broadcast p50 / p95 / p99 | 38 / 127 / 205 ms | 45 / 162 / 270 ms | 8 / 36 / 85 ms | 8 / 50 / 131 ms |
| Acknowledgement p50 / p95 / p99 | 86 / 332 / 676 ms | 102 / 428 / 1,331 ms | 18 / 71 / 132 ms | 19 / 94 / 249 ms |
| p99 broadcast under 250 ms | pass | fail | pass | pass |
| Errors / disconnects / turns twice | 0 / 0 / 0 | 0 / 0 / 0 | 0 / 0 / 0 | 0 / 0 / 0 |
| Chips | exact | exact | exact | exact |
| Silo CPU (cores of 32) | 14.0 | 13.6 | 8.4 | 8.9 |
| Silo cores per 100 hands/s | 6.08 | 5.95 | 3.64 | 3.84 |
| Memory per silo | 1,094 MiB | 1,181 MiB | 399 MiB | 402 MiB |
| Stage length, seating included | 1,315 s | 1,240 s | 719 s | 682 s |
Measured on 27 September 2026, after Releases A–C. The second run of 26 September is the build before: it missed the budget once in two.
A load generator that tells the truth
Less work per hand
Certified on reference hardware
The path to a million
One node to a region.
The same code all the way.
Architected for
1,000,000
registered players per liquidity pool
sized for 50,000 concurrent and 10,000 tables per pool, with 5× headroom
Architected for
50,000
concurrent players per liquidity pool
about 30,000 seated and 20,000 in the lobby or watching, at 10,000 tables
Architected for
200
hands per second per pool
about 2,400 actions/s, 15,000–25,000 outbound messages/s and 3,000 settlement rows/s
Architected for
5×
headroom in the design
50,000 tables, 1,000 hands/s and 15,000 settlement rows/s per pool
- S0
Single node
When: Launch
Where every install begins — and the same code the largest cluster runs, nothing forked for it.
- A whole room on one machine. The gateway and the game tier co-host in one process, so a complete poker room runs on a single node.
- S1
Split tiers
When: Beyond ~10,000 concurrent connections
Stateless gateways and an actor cluster, each scaled on its own signal, with routed delivery in place of a backplane.
- Tiers that scale independently. Gateways scale on connections, silos on tables, the staff tier on its own — each a separate Kubernetes workload.
- Routed delivery, no backplane. A table's events go only to the gateways that hold its viewers — no broadcast bus to saturate.
- S2
Partitioned data
When: Beyond ~50 hands/s sustained
Month-by-month storage, sharded house accounts, the outbox to Kafka, analytics in ClickHouse and a cached lobby.
- Partitioned storage. Ledger entries, hands and their events stored month by month, house accounts spread over sixteen shard rows, and hand events through the outbox to Kafka and ClickHouse.
- A lobby that never reaches the game tier. The lobby is a read model in Valkey, coalesced every 250 ms; when the cache cannot answer, the lobby grain does.
- S3
Sharded ledger
When: Primary writes above 50%, or 10,000-entrant events
Wallets and the ledger sharded by player; a tournament field spread across actors.
- A ledger sharded by player. Wallets and the ledger sharded by player with Citus, on a schema that carries the player on every money row.
- 10,000-player fields. Tournament child grains spread one field across the cluster.
- S4
Regional pools
When: Entry into a segregated market
A cluster per region and liquidity pool — another release of the same chart with different values.
- Liquidity pools across regions. Each region its own cluster and liquidity pool, scaled out one release at a time.
Fifty thousand players, measured.
A million, by design.
A walkthrough of the architecture, the load generator and the run behind every number on this page — and how your own peak maps onto it.