Our poker engine runs inside the database: four months on SpacetimeDB
Four months in, the platform gave us transactional fan-out and privacy by construction, and left us owning reconnect, presence, retention and the shuffle.
In June we picked SpacetimeDB as the whole server for a six-seat online poker app. Not a database behind an app server. The server. Tables, reducers, subscriptions and the rules engine all live in one process, and the iOS client is a subscriber that sends commands.
The thing works: cash tables, single-table freezeouts, bots, a store, VIP passes, a hand-replay audit trail, and multi-table tournaments we are still hardening. It is in TestFlight with play money. This is the honest ledger of what the platform gave us, what we still had to build ourselves, and where it will break if we push it.
The bet
Server-authoritative poker needs three things. Hidden state that never leaves the server, except each player’s own two cards. Validation of every intent, then low-latency fan-out of the public result. And a consistent, versioned view for every seat at the table.
The conventional stack for that is four systems: a database, an application server, a WebSocket layer, a pub/sub bus. You own three consistency seams between them, and every one of them is a place where a chip can be created or lost.
SpacetimeDB collapses those into one. A reducer is a transaction: either the hand state, the public projection rows and the ledger row all land, or none of them do. Fan-out is the subscription mechanism, not something we wrote. That single property is why we chose it, and four months in it is still the reason.
Two decisions made it stick. The first is that nothing above our PokerClient interface knows SpacetimeDB exists: the renderer, the domain engine and the protocol types are vendor-free, and a boundary checker enforces that mechanically. The second came a day later, when we found out SpacetimeDB 2.0 runs TypeScript modules on V8.
The engine runs inside the database
We had already written the poker rules as a pure TypeScript package: deal, bet, settle, no I/O, no clocks, no randomness of its own. The original plan was to port it to Rust for the module and keep the TypeScript copy as a test oracle, replaying identical hands to prove the port.
TypeScript modules killed that whole workstream. The same engine package the client tests use now executes inside the database. There is one implementation of the rules, not two that can drift.
What that module looks like today:
| In the module | Count |
|---|---|
| Tables | 95 |
| Reducers | 92 |
| Views | 22 per-sender, 5 anonymous |
| Row-level-security filters | 1 |
| Scheduled reducers (timers) | 13 |
| Lines in the module entry file | 15,445 |
Three things about running TypeScript in there were better than expected. bigint chip amounts round-trip exactly through u64 columns, including values above Number.MAX_SAFE_INTEGER, so money never touches a float. The runtime removes the impure escape hatches: there is no stdlib clock and Math.random is replaced with a throw, which happens to be exactly the discipline our pure engine already needed. And tables are private by default, so forgetting to hide something is not the failure mode.
The published performance figure is roughly 100k transactions per second for TypeScript against 150k for Rust. A six-seat cash table needs orders of magnitude less than that, and our hot reducers run in 0.7 to 1.9 ms, so the gap has never been the constraint.
Privacy by construction, which is not the same as privacy by default
The deck and both hole cards live in one private JSON blob on the table row. No client subscribes to it, and no SQL read by a non-owner can reach it. What clients see are purpose-built projections written in the same transaction as the state change: public_table, public_seat, a hand-result event stream.
Your own cards come from a per-sender view. Those views find the caller’s seat through index lookups on the caller’s own keys, which matters more than it sounds like: SpacetimeDB 2.10 records view read sets per index key, so an action at table 7 re-evaluates only the views of the clients seated at table 7. Write a view that scans a table instead, and every subscriber’s copy re-runs on every write to it.
We learned that lesson once per view. tournament_summary originally read the clock and iterated the field; it is now an anonymous view over point lookups, with the live level written by the deal path. The entrant projections iterated every tournament config ever created, which made them grow with history: 3.6 ms at 1,397 kept entrants, about 1 ms once bounded.
The whole schema has exactly one row-level-security rule, on the replay-frame table. Everything else is either private, an anonymous projection, or a per-sender view keyed by the caller. I think that is the right shape, but it means we hand-rolled a lot of visibility logic that an RLS-first design might have expressed declaratively. More on that below.
What we still had to build
Here is the part nobody puts in the pitch deck.
Reconnect, four layers of it. A module publish can leave a running client unable to decode rows. The SDK’s message handler has no catch, the error escapes, and the socket still reports connected: the table just freezes, silently, forever. Our transport closes that with global error hooks that classify the decode signature, a deadline watchdog (the server always auto-acts at an action deadline, so a deadline passing with no row event proves a dead pipe), a 15-second subscription apply budget, and the rule that every drop path settles the in-flight connect promise and rejects every in-flight reducer call. All four funnel into one reconnect path. The last two came from real bugs: an unsettled connect promise is a zombie that kills every future reconnect, and the SDK will happily queue a call made on a closing socket and never settle it, which left a Register button disabled forever.
Presence, because disconnect is slow. We measured how long a silent network drop takes to reach the module’s disconnect callback on 2.10.2: 26 to 38 seconds, mean 32.9 over six runs. That is not a defect, it is the sum of the server’s defaults (ping every 15 s, idle at 30 s after the last client message, then a 10 s close grace), and it is far too slow to show “away” on a tournament seat. A clean socket close arrives in 4 to 13 ms; it is the phone-leaves-wifi case that is slow. So we built presence rows plus a 5-second heartbeat, which gets a drop noticed in 7 to 12 seconds. The platform did give us one thing for free here: when the host launches a database it runs the disconnect reducer for every connection still recorded, so a host crash leaves no stale presence row. We verified that twice by killing the server.
Retention, because storage is a ratchet. Our audit tables (every hand’s setup, every action) grew without bound. On Maincloud, index key data is metered at $10/GB/month, and one bots-only table produced about 100 MB a month. Reducer calls are cheap in dollars; storage is the bill that never stops arriving. We now keep the newest 2,000 hands of audit per table, cap displayed history at 200, and sweep an ended game’s rows 30 days after it ends, 500 hands per run.
The shuffle. This one is the most interesting, and the least comfortable. The module SDK gives reducers a ctx.random, and our ADR said it was “host-seeded, deterministic per reducer call”. Reading the shipped SDK last week showed otherwise: there is one PRNG stream per module instance, seeded from the timestamp of the first reducer that touched it, and it is not re-seeded between calls. That is 2^32 possible states keyed on a reducer timestamp, shared between HTTP calls, scheduled timers and deals. A probe on a scratch database predicted three consecutive decks with no search, one of them from a public timestamp the client can already see. The V8 host installs no CSPRNG at all: globalThis.crypto is undefined. So the deck now comes from our own Fisher-Yates over a SHA-256 counter stream, keyed by an HMAC of a private secret, the hand id and the deal timestamp, with SHA-256 and HMAC written in pure TypeScript inside the module and checked against RFC 4231. ctx.random kept bot pacing and tournament seating.
I do not think this is a knock on the platform, and the open question is going to Clockwork Labs rather than into a blog post: the type definitions describe fill as being “like crypto.getRandomValues()”, which reads like an intent we could not verify in the implementation. But it is the clearest example of the rule that matters here. Where correctness is ours, a platform’s type definitions are a claim, not evidence.
The schema is a one-way contract, and mobile makes it worse
This is the hardest constraint of the whole stack, and it is the one I would warn a new team about first.
SpacetimeDB stores rows as typed BSATN and auto-migrates only an additive subset. There is no in-place ALTER TABLE. The only way to force a forbidden change is --delete-data, which wipes the entire database: accounts, chips, history, everything.
Adding a new table or view is free, with no disconnect. Changing a view’s body is free. Adding an index or changing reducers is free. But appending a column to a table a shipped client subscribes to disconnects every connected client, and that is where mobile bites. A web app you reload. An App Store bundle is out in the world for months. We proved what happens on a scratch database: append a defaulted column, force it through, and clients whose bindings predate the append cannot decode the widened row on reconnect. The SDK throws, the socket closes on the throw, and the bundle reconnects on backoff forever. Eleven failed reconnects in 90 seconds, no recovery path, no deploy order that fixes it, because old bindings misread the new row and new bindings would misread the old one. Fixed-width rows are worse: they decode silently into garbage.
So the rules we live by now are:
- Never widen a relation a shipped bundle subscribes to. New data goes in a companion table or view, or as new keys in JSON a string column already carries, parsed client-side.
- Every new subscribed relation ships module-first: publish, prove it is there with a query whose answer differs per database, then push the app update. A relation missing on the server fails the whole subscription, and the client reconnects forever.
- Retire a reducer by keeping its signature and rejecting, never by removing it. Older bundles keep calling what they shipped with, and parameters are positional.
- Our publish command for the live databases deliberately leaves the “break clients” confirmation out of its auto-yes list, so that prompt reads end-of-file and the publish aborts before anything changes.
A checker (pnpm ota:subscriptions) enforces the deploy order by name. It cannot catch a widened row shape, which is why that one is a rule written in three places.
React Native: four things you only find by shipping
The JavaScript SDK works on Hermes, with four adjustments.
- One subprotocol. The SDK negotiates both v3 and v2 by default. React Native, Android especially, has historically mishandled multi-protocol handshakes, so we open the socket with a single protocol string, not a one-element array.
- No gzip. Hermes has no
DecompressionStream, so native asks for uncompressed frames and only web keeps gzip. That shows up in the bill: a seated native client receives 280 to 340 KB a minute at a 64-player field, and about 600 KB a minute at a 512-player field with busts. - Measure egress at the client. The server’s own byte counter undercounted what our clients actually received by up to 5.9x when several clients shared a table.
- Resolve the auth token per connect attempt, not once. Our access tokens live 15 minutes, and the SpacetimeDB credential is that token. A transport that remembers its construction-time token rejects forever after a backgrounded drop.
And one platform asymmetry worth planning for: native has no global error / unhandledrejection hooks, so two of our four staleness layers do not exist there. The deadline watchdog and the apply budget are the whole net on a phone. We keep them honest with tests because the layer that would have caught the failure on web is simply absent.
Local development is the quiet win
spacetime start runs the entire stack on a laptop with no account, no API key and no cloud project. That sounds minor and it changed how we work.
Every risky question gets answered on a throwaway database. The widened-row behaviour, the timer-restart semantics, the disconnect latency, the deck predictability: all measured on private instances on a known port with their own data directory, never on anything that serves a player. Publishing a scratch module takes seconds, and publish -c deletes the data, which is fine there and categorically forbidden on the real one.
That cheapness is what made the test strategy possible. Our server tests run against real databases, in two tiers: a fast tier where every test is under 30 seconds and the whole thing finishes in about three minutes across two lanes, and a long tier that runs nightly. On top of them sits the check I would recommend to anyone building a game of chance on this platform: an invariant checker that replays every persisted hand through the pure engine. A clean run proves the entire live history obeyed no-limit hold’em, because the engine cannot accept an illegal action. It reads one hand at a time by index, it fails closed on any bad read, and it carries a self-test proving it rejects a below-minimum raise, because a history check that cannot fail is just a green light. The last run replayed 751 tables, 3,461 hands and 8,190 actions in 18 seconds, and its offline twin proves those properties against 13,000 setup rows, a dropped row and a lost final action.
Two scars from that tooling. The checker used to buffer the whole result set through one JSON string, which exceeded V8’s roughly 512 MB string cap past about 12,000 rows and then failed open, looking exactly like a passing run. And the SQL endpoint speaks a narrow dialect: no scalar subqueries, no SUM(), and one transaction per HTTP call, so reading seat stacks and pots in two calls lets a hand boundary land between them. Conservation checks have to be a single statement.
Production, and what it costs
Maincloud meters six resources (storage byte-seconds, egress, CPU, index seeks, bytes written, bytes read) and converts them to energy. It is not per connected client and not per hour of uptime, which suits a game with idle tables.
The finding that changed our design: the free tier pauses a database only after no client connections and no reducer calls. Our bots are driven by scheduled reducers, so a bots-only table generates calls forever with zero humans connected. It defeats scale-to-zero by construction. A table left grinding keeps the database awake, bills storage forever, and the loop’s only natural end is bots busting each other out, which is thousands of hands. So an idle bots-only table now turns itself off after 250 hands with nobody seated. That cap is not primarily about compute, it is about letting the database sleep at all.
The modelled compute is genuinely cheap: one continuous table at about 1,630 hands a day is roughly 2 million reducer calls a month, about $0.64 at list. But that is also about 67% of the entire free-tier monthly credit for a single table. Compute is cheap and credits are small, which is a different thing.
One more production note: versions differ. Maincloud runs 2.12.0 as I write this, which is ahead of what our laptops install by default, and we have hit at least one bug (autoinc ids colliding after a restart) that was fixed in a release between the two. Every server reports its version at /v1/health, and we read it before trusting any measurement.
Could this run 1,000 tables?
Not as it is built today, and the reason is not the one I expected. The tables are cheap: about 0.3 ms of transaction CPU each to create and seat, 3.1 to 3.5 hands a minute per table whatever N is, roughly one core and 3,400 hands a minute for 1,000 bot tables. What breaks is the subscription design. A view declared per-sender is materialized once per connected identity and re-evaluated on every write to the relation underneath, inside the transaction that holds the database’s only write lock, so the cost is subscribers times rows, paid per write. At 1,000 listed rooms with 100 people connected, one beat of one hand costs 400,101 index seeks.
We measured that properly in September, and it is long enough to be its own piece: One write lock: measuring SpacetimeDB’s subscription cost at 1,000 tables has the numbers, the remedy, and the thing I guessed wrong.
What we have not used yet
Four months in, our map of the platform has obvious blank regions.
- Row-level security. We have one filter and 22 hand-written per-sender views. Some of those are genuinely computed projections, but some are just “the caller’s rows”, which is what RLS is for.
- Light mode and confirmed reads. Our socket opener passes both through and the app sets neither. Light mode in particular looks like free egress on a phone.
- The client-side cache as a prediction surface. Today the renderer draws server snapshots only, which is a deliberate choice (the server is the validator, and presentation never delays the controls). But the SDK keeps a local cache we have never used to predict a local action and reconcile.
- More than one database. One database means one write lock. The natural shard for poker is a table, or a region, and we have never tested the client against several databases at once.
- A Rust module for one hot path. The transport seam means we could move the hand loop and keep everything above it, and the TypeScript module would still be the reference. We have no reason to yet, which is the correct state.
The honest reason for the blanks is that the first four months went on correctness and on the things the platform does not do for you, not on its ceiling. Reconnect, presence, retention, the shuffle, the schema discipline: none of that is poker, and all of it was load-bearing.
Would I pick it again
Yes, and for the reason I picked it, not for the extras. A reducer that moves chips, writes the projection, appends the ledger row and arms the next timer in one transaction is worth a lot in a game where a lost chip is a bug report with money attached. Having the rules engine run inside that transaction instead of beside it is worth more.
What I would tell myself in June: budget for the schema contract from day one, because on mobile it is the constraint that shapes your data model more than anything about poker; measure the platform’s behaviour on a scratch database instead of trusting its documentation, which is cheap enough here that there is no excuse; and write the per-subscriber cost of every global view down on a wall, because that number, not the table count, is your scale.