Skip to content
PokerEnginePro
Scale · Orleans · Protocol 1.1

Architected for a million.
Measured at fifty thousand.

Every table, player, lobby and tournament is an actor the cluster places and moves. Gateways scale on connections, the game tier on tables, and a region is added as its own cluster — so capacity grows by adding machines, not by rewriting anything.

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.

Orleans virtual actors: every table, player session, lobby and tournament is its own grain, placed and moved by the cluster.

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.

A shared message bus between gateways carries every table's traffic to every node, and caps out in the tens of thousands of messages a second. Here a table sends each burst of events once, as one ordered batch, only to the gateway nodes that hold its players — and each gateway writes it to the sockets it holds.
Routed delivery, without a backplaneA table's actor on a silo sends each burst of events as one ordered batch to gateway A, the only gateway holding players who watch that table. Gateway B holds none of them and is sent nothing. Gateway A writes the batch to its three players' sockets.silo 2 · game tierRoomGrain · table 4127one ordered batch per burstbatchnothing sentGateway Aholds 3 viewersGateway Bother tables onlywritten to each socketno backplane · no fan-out to idle nodes
IGatewayGrain.cs
/// 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);
}
Excerpt from platform/Poker.Platform.Grains/Abstractions/IGatewayGrain.cs (doc comments shortened) — versioned, so old and new gateways share a cluster during a deploy.

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.

Gateways scale on live connection count through the HPA or KEDA, from an in-cluster or a managed Prometheus.

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.

What each tier scales on
TierScales 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

values.yaml · gateway.autoscaling
autoscaling:
  enabled: true
  provider: hpa    # or keda, for a managed Prometheus
  minReplicas: 2
  maxReplicas: 20
  targetConnectionsPerPod: 8000
From packaging/helm/pokerenginepro/values.yaml (comments shortened). The cloud page has the KEDA variants.

Protocol 1.1

Fewer, larger messages.
The same events, a quarter of the messages.

A 1.1 client receives one message per burst — everything one action set off — with the room id and the server time once per batch, and builds its own turn from the events. Fewer messages matter most on phones: every message wakes the radio and pays its own overhead. A client that asks for nothing gets 1.0, unchanged, at the same table.

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

Per player per hand, same tables, same events
Received1.01.1Change
Bytes received42.3 KB22.7 KB−46%
Messages received11429−75%
Events received104100the same content
Private YourTurn messages4.30built by the client
Bytes sent1.08 KB1.07 KBunchanged

Measured on 26 September 2026 — 40 tables × 5 players, every frame captured.

protocol 1.1 · RoomEvents
/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
}
From the protocol specification (comments shortened and moved above their fields). Old and new clients sit at the same tables; the gateway sends each the protocol it asked for.

Failover and resync

Lose a server, lose one hand.
Drain a server, lose nothing.

A server lost mid-hand voids only that hand and refunds it exactly once; a server drained for a deploy voids none. Sequenced events, gap detection and snapshot resync; players rejoin every table after a reconnect, through any gateway.

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

  1. 1Its tables reactivate on the silos that remain.
  2. 2Each room voids the hand it was dealing when it comes back — that hand, and no other.
  3. 3Every chip committed to it is refunded exactly once; nothing settles twice.
  4. 4Players resync from a snapshot and the next hand deals.

A silo is drained for a deploy

  1. 1Readiness turns to 503 and the node leaves rotation.
  2. 2No new hand starts on it.
  3. 3Hands in progress play out, for up to 120 seconds.
  4. 4Kubernetes stops the pod only after the drain. No hand is voided.

A connection drops

  1. 1Every event carries its room's sequence number.
  2. 2A gap means a missed event: the client asks for a snapshot.
  3. 3After a reconnect, through any gateway, it rejoins every table it had open.
  4. 4The snapshot carries the player's pending turn, so a decision is never lost.

Measured

Fifty thousand players.
On ten thousand tables.

The exit criterion's shape — 50,000 connections at 10,000 tables, p99 action-to-broadcast under 250 ms, not one chip out of place — played on a single machine with the load generators running beside the cluster, before and after the releases that cut the servers' work per hand.

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

Before

38–45 ms

After

8 ms

Action to broadcast, p99

Before

205–270 ms

After

85–131 ms

250 ms budget

Acknowledgement, p99

Before

676–1,331 ms

After

132–249 ms

Silo cores per 100 hands/s

Before

5.95–6.08 cores

After

3.64–3.84 cores

Memory per silo

Before

1,094–1,181 MiB

After

399–402 MiB

Seating 50,000 players, stage length

Before

1,240–1,315 s

After

682–719 s

Lower is better on every panel. Each bar spans the two runs of one build: solid to the better run, lighter to the worse. Each panel has its own scale.

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

Results of the 50,000-player runs before (26 September) and after (27 September) Releases A–C
Metric26 Sep · run 126 Sep · run 227 Sep · run 127 Sep · run 2
Connections50,00050,00050,00050,000
Hands per second229.8227.9232.0231.7
Actions per second3,7303,6613,8163,810
Broadcast p50 / p95 / p9938 / 127 / 205 ms45 / 162 / 270 ms8 / 36 / 85 ms8 / 50 / 131 ms
Acknowledgement p50 / p95 / p9986 / 332 / 676 ms102 / 428 / 1,331 ms18 / 71 / 132 ms19 / 94 / 249 ms
p99 broadcast under 250 mspassfailpasspass
Errors / disconnects / turns twice0 / 0 / 00 / 0 / 00 / 0 / 00 / 0 / 0
Chipsexactexactexactexact
Silo CPU (cores of 32)14.013.68.48.9
Silo cores per 100 hands/s6.085.953.643.84
Memory per silo1,094 MiB1,181 MiB399 MiB402 MiB
Stage length, seating included1,315 s1,240 s719 s682 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

Headless players measure action-to-broadcast, check the chips from outside and fail the run when the books or the latency budget do not hold.

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.

Certified on reference hardware

Load-certified at 50,000 concurrent players on reference hardware, with a gateway and a server killed mid-run.

The path to a million

One node to a region.
The same code all the way.

Five stages, each triggered by a number rather than a date. Nothing is rewritten between them: each stage changes where things run, not what they do.

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.