<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Alberto Vasquez</title><description>Alberto Vasquez — building a poker platform and a game theory optimal solver in Rust, and writing up the engineering as it happens.</description><link>https://albertovasquez.com</link><language>en-us</language><item><title>One write lock: measuring SpacetimeDB&apos;s subscription cost at 1,000 tables</title><link>https://albertovasquez.com/writing/one-write-lock-at-1000-poker-tables</link><guid isPermaLink="true">https://albertovasquez.com/writing/one-write-lock-at-1000-poker-tables</guid><description>The tables are the easy part: roughly one core and 3,400 hands a minute at 1,000 of them. What stops the deployment is that a per-sender view is materialized once per connected client and re-evaluated inside the database&apos;s one write lock.</description><pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A 1,000-table poker deployment on SpacetimeDB is not a compute problem. One core deals roughly 3,400 hands a minute across 1,000 bot tables, and the hot reducers do not get slower as the table count grows. What stops you is 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&apos;s only write lock. At 1,000 listed rooms with 100 people connected, one beat of one hand costs 400,101 index seeks and 3.3 seconds of that lock.&lt;/p&gt;
&lt;p&gt;We run a six-seat online poker app whose entire server is SpacetimeDB: 95 tables, 92 reducers, and the poker rules executing as TypeScript inside the database, with the iOS client as a subscriber that sends commands. &lt;a href=&quot;https://albertovasquez.com/writing/our-poker-engine-runs-inside-the-database&quot;&gt;Our poker engine runs inside the database&lt;/a&gt; is the four-month ledger of that. This is the one question I most wanted answered, measured properly in September, because a multi-table tournament is the same question wearing a hat.&lt;/p&gt;
&lt;h2&gt;The tables are the easy part&lt;/h2&gt;
&lt;p&gt;One transaction that creates N tables, seats the field and deals N first hands costs about 0.3 ms of transaction CPU per table, with exactly 38 rows inserted, 10.5 KB written and 31 index seeks per table. A 128-table start with 1,024 bot seats took 35 to 42 ms, and none of 57 such starts failed.&lt;/p&gt;
&lt;p&gt;Hand loops are linear too: each table deals 3.1 to 3.5 hands a minute at a 20-second clock whatever N is, the hot reducers do not get slower as N grows, and the instance spends about 0.001 cores per table. 128 tables dealt 432 hands a minute on 0.11 cores.&lt;/p&gt;
&lt;p&gt;Extrapolate that arithmetic and 1,000 bot tables is about one core and roughly 3,400 hands a minute. The server is not what stops you.&lt;/p&gt;
&lt;h2&gt;The clients are&lt;/h2&gt;
&lt;p&gt;Two field-wide views in our base subscription were declared per-sender although they never read the sender, which means SpacetimeDB materializes one copy per connected identity and re-evaluates every copy on every write to the underlying table, inside the transaction that holds the database&apos;s only write lock. Measured on 2.10.2, one write to the tournament entry table cost:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Connected clients&lt;/th&gt;
&lt;th&gt;Field 64&lt;/th&gt;
&lt;th&gt;Field 128&lt;/th&gt;
&lt;th&gt;Field 512&lt;/th&gt;
&lt;th&gt;Field 1,024&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.12 ms&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;12.1 ms&lt;/td&gt;
&lt;td&gt;14.5 ms&lt;/td&gt;
&lt;td&gt;26.4 ms&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;34 ms&lt;/td&gt;
&lt;td&gt;61 ms&lt;/td&gt;
&lt;td&gt;99 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That is the shape of the ceiling: subscribers times rows, paid per write, serialized on one lock. A fit on those medians is about &lt;code&gt;-1.2 ms + S x (0.23 ms + 0.75 us x F)&lt;/code&gt; up to 100 clients, and past 100 every number is an extrapolation whose model forms disagree by 2 to 4 times.&lt;/p&gt;
&lt;p&gt;Two details make it worse than the table suggests. A disconnected client&apos;s view instances keep being re-evaluated until a cleanup pass drops them, which we watched take 16.5 minutes both times we measured it. And the cost is paid by &lt;em&gt;every connected client&lt;/em&gt;, not just the ones in that tournament.&lt;/p&gt;
&lt;h2&gt;Then we measured the lobby, and it is worse&lt;/h2&gt;
&lt;p&gt;The tournament views at least only matter during a tournament. &lt;code&gt;public_active_game&lt;/code&gt; is the relation every phone subscribes to for its whole session, and it does four index seeks per listed room plus one lookup for the branch that shows a player their own practice room, repeated per subscriber, inside whatever write triggered it. The triggering write is the most ordinary one there is: settling a street touches &lt;code&gt;public_table&lt;/code&gt;, so this is the cost of a heartbeat of poker.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Connected clients&lt;/th&gt;
&lt;th&gt;8 rooms&lt;/th&gt;
&lt;th&gt;64 rooms&lt;/th&gt;
&lt;th&gt;256 rooms&lt;/th&gt;
&lt;th&gt;1,000 rooms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.26 ms&lt;/td&gt;
&lt;td&gt;0.26 ms&lt;/td&gt;
&lt;td&gt;0.26 ms&lt;/td&gt;
&lt;td&gt;0.26 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;7.0 ms&lt;/td&gt;
&lt;td&gt;16.8 ms&lt;/td&gt;
&lt;td&gt;46 ms&lt;/td&gt;
&lt;td&gt;255 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;18.1 ms&lt;/td&gt;
&lt;td&gt;62.8 ms&lt;/td&gt;
&lt;td&gt;222 ms&lt;/td&gt;
&lt;td&gt;1,391 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;30.2 ms&lt;/td&gt;
&lt;td&gt;110 ms&lt;/td&gt;
&lt;td&gt;667 ms&lt;/td&gt;
&lt;td&gt;3,328 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Measured on 2.12.0, the version Maincloud runs, on a busy machine, so read the ratios rather than the absolutes. The seek counts are the part that no load can distort: &lt;code&gt;S x (4G + 1) + 1&lt;/code&gt;, which comes to 400,101 index seeks for one beat of one hand at 1,000 rooms with 100 people connected. Put it against a lock budget of 50 ms per write and the lobby view alone allows about 80 connected clients at 8 rooms, 33 at 64, 11 at 256, and 3 at 1,000. We list six rooms in beta today. That is the only reason this is a growth problem and not a live one.&lt;/p&gt;
&lt;p&gt;Declaring that one view anonymous removes the client dimension completely: 1.4 ms at 8 rooms, 4.0 at 64, 12 at 256, 26 at 1,000, and the same whether 10 or 100 clients are connected. In the worst cell that is 128 times cheaper. What it does not remove is the room dimension, because the body still walks every room on every write, about 1.2 ms plus 24 microseconds per room. At 1,000 rooms that is 25 ms of write lock for every beat of every hand, so a lobby that big needs a projection that is not recomputed from scratch. That is a different and harder problem, and we have not solved it.&lt;/p&gt;
&lt;h2&gt;The three things I would do&lt;/h2&gt;
&lt;p&gt;A 1,000-table deployment is not a compute problem, it is a subscription-design problem.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Make anything field-wide or lobby-wide an anonymous view.&lt;/strong&gt; We measured the remedy on one of them: declaring it anonymous instead of per-sender cut the same write from 12.1 ms to 3.8 ms at a 64-entrant field, and from 17.3 to 6.7 at 256. Our lobby view is still per-sender today, and with 1,000 tables it would materialize a 1,000-row copy per connected phone, re-evaluated on every street of every hand at every table. It is the first thing that breaks. The flip is not always one word, though: the lobby&apos;s body genuinely does read the caller, because a practice room is listed only for its owner, and switching a view from sender-scoped to anonymous is itself planned as a disconnect-everyone change. So the real move is to publish an anonymous projection and a small per-sender view for the one personal branch beside the shipped one, then move the subscription in an app update. Adding views is compatible, and a view nobody subscribes to costs nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do not expect a narrower subscription to save you.&lt;/strong&gt; This was my own wrong guess, and the measurement killed it: with every client asking for one room instead of all 256, the same write did the same 51,251 index seeks and cost no less. A per-sender view is materialized per identity first, and the query filters its result afterwards. Paging the lobby is good for egress and does nothing for the write lock. The relation itself has to stop being per-sender.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make the retention caps global, not per table.&lt;/strong&gt; Ours keep 2,000 hands of audit per table, which is exactly right at 200 tables and 2 million rows of ratcheting storage at 1,000.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;And the dashboard the question started from&lt;/h2&gt;
&lt;p&gt;The one that watches 1,000 tables and all their transactions: do not build it on subscriptions. Build it on the two things that are already cheap. The server exposes a Prometheus endpoint per database (transaction CPU per reducer, rows inserted, bytes written, index seeks, bytes sent) which is how we got every number here. For game state, read narrow SQL over HTTP, remembering that each call is its own transaction, and have reducers maintain the aggregates you want to display, since the SQL dialect will not sum them for you.&lt;/p&gt;
&lt;p&gt;A dashboard that subscribes to everything is just another connected client multiplying the write lock.&lt;/p&gt;
</content:encoded><category>spacetimedb</category><category>poker</category><category>architecture</category></item><item><title>Fitting a 27 GiB poker solve into 8.6 GiB: 8-bit regrets, and what didn&apos;t work</title><link>https://albertovasquez.com/writing/fitting-a-27-gib-poker-solve-into-8-6-gib</link><guid isPermaLink="true">https://albertovasquez.com/writing/fitting-a-27-gib-poker-solve-into-8-6-gib</guid><description>Our full no-limit postflop tree needs 27.4 GiB in the best public 16-bit layout. It solves in 7.8–8.6 GiB on a 16 GiB Mac Mini, because 8-bit regrets survive stochastic rounding — and strategy sums do not.</description><pubDate>Fri, 16 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;One full postflop tree, no abstraction: 27.4 GiB in the 16-bit layout the best open-source solver uses, about 55 GiB in 32-bit floats. We solved it in 7.8 to 8.6 GiB, on an M2 Mac Mini with 16 GiB where about 6 GiB is already taken.&lt;/p&gt;
&lt;p&gt;Take one flop: BTN opens, BB calls, 100bb deep, K72 rainbow. Give each player two bet sizes on every street, let either player shove once the SPR is 5 or lower, and deal every turn and river card individually. The exact sizes don&apos;t matter for this post; the shape of the tree does. The solver has to keep 6.18 billion numbers for that.&lt;/p&gt;
&lt;p&gt;There&apos;s no new algorithm here; every piece comes from a paper, credited as I go. What I have is a ledger of what fit, what it cost, and what failed, each with its number. One result in it surprised me, and I couldn&apos;t find it in the literature.&lt;/p&gt;
&lt;h2&gt;What a solver does&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;Familiar with CFR already? Skip to &lt;a href=&quot;#where-the-memory-goes&quot;&gt;where the memory goes&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;A poker solver starts with any strategy, say uniform random, and plays the game against itself over and over. After each pass it asks, at every decision: how much better would I have done if I&apos;d always taken that other action? That shortfall is called regret. The solver shifts probability toward the actions it regrets not taking, and repeats. This is counterfactual regret minimization, or CFR (Zinkevich, Johanson, Bowling and Piccione, 2007).&lt;/p&gt;
&lt;p&gt;The update rule is regret matching (Hart and Mas-Colell, 2000). Play each action in proportion to its positive cumulative regret, never play one whose regret is negative, and play uniformly if nothing is positive. CFR guarantees the average regret per iteration shrinks like 1/√T, and in a two-player zero-sum game that means the &lt;em&gt;average&lt;/em&gt; strategy approaches a Nash equilibrium. (Yes, that Nash, the one Russell Crowe played in &lt;em&gt;A Beautiful Mind&lt;/em&gt;. A Nash equilibrium is a pair of strategies where neither player can do better by changing theirs alone, which is exactly what we want a solver to find.) The current strategy can keep oscillating forever. Only the running average converges.&lt;/p&gt;
&lt;p&gt;So a solver keeps two big tables per decision: cumulative regrets, which drive the next strategy, and strategy sums, which form the average you ship. To grade the answer we compute exploitability: how much a perfect opponent could win against it, as a percentage of the pot. I&apos;ll call that the gap from here on. Our bar is under 1% of the pot. For comparison, solver guides suggest stopping below 0.5% of the pot when you study a spot (&lt;a href=&quot;https://www.deucescracked.com/poker/strategy/solver-fundamentals&quot;&gt;DeucesCracked&lt;/a&gt;); our full-tree runs below land between about 0.6% and 0.9%.&lt;/p&gt;
&lt;h2&gt;Where the memory goes&lt;/h2&gt;
&lt;p&gt;Our engine runs discounted CFR (Brown and Sandholm, 2019), written in Rust. It walks the betting tree with every combo of both players&apos; ranges at once, so each decision point holds a row of numbers per combo per action, once for regrets and once for sums.&lt;/p&gt;
&lt;p&gt;The river is the whole story. A flop decision point gets visited once per pass, a turn one once per turn card (49 times), and a river one once per turn and river pair (2,352 times). Of the 6.18 billion stored values, 99.6% are on the river.&lt;/p&gt;


Figure 1. The same tree in three layouts, against the 16 GiB machine and the 10 GiB a solve is allowed.

&lt;h2&gt;Step 1: 16-bit integers, one scale per combo&lt;/h2&gt;
&lt;p&gt;The first move is ordinary. Store each value as a 16-bit integer and give each combo&apos;s row its own power-of-two scale, so a combo with tiny regrets isn&apos;t crushed by a neighbour with huge ones. That&apos;s block floating point, which is decades old. The best public open-source solver uses one scale per node.&lt;/p&gt;
&lt;p&gt;It gives the same convergence curve as 32-bit floats, with per-combo EVs within 0.06% of the pot. What I didn&apos;t expect was how much the granularity matters. One scale for a whole table starved the small-regret combos and came out 9x worse.&lt;/p&gt;
&lt;p&gt;That gets us to 27.4 GiB. Nowhere near 10.&lt;/p&gt;
&lt;h2&gt;Step 2: 8-bit regrets&lt;/h2&gt;
&lt;p&gt;Halving again means 8 bits per regret. Done the obvious way, with round-to-nearest, it stalls. On a thinned K72 rainbow flop (178 and 187 combos), 8-bit regrets with plain rounding flattened out at 1.5% of the pot: 1.55% at 250 iterations, 1.51% at 300. The 16-bit run kept falling, to 0.59%. Make the strategy sums 8-bit too and it&apos;s a wreck: 22.8% of the pot on a small river spot, against 0.04% at 16 bits.&lt;/p&gt;
&lt;p&gt;A small example shows why. Start an integer at 0 and add 0.3 to it a thousand times, rounding to the nearest integer after each add. Every add rounds back to 0, so you finish at 0 instead of 300. Late in a solve, most regret updates are small compared to the row&apos;s scale. Anything under half a unit vanishes, and the regrets stop moving. That&apos;s also why I didn&apos;t see it at first: on a tiny river spot the updates stay big, and plain-rounded 8-bit regrets did as well as 16-bit (0.052% against 0.053%). It takes a real flop, with lots of small updates, to show the stall.&lt;/p&gt;
&lt;p&gt;Stochastic rounding fixes it. Round 0.3 up with probability 0.3 and down with probability 0.7. Any single result is wrong, but each add is 0.3 on average, and after a thousand adds you&apos;re near 300. Numerical analysts know this well (Connolly, Higham and Mary, 2021), and so does the low-precision deep learning crowd (Gupta, Agrawal, Gopalakrishnan and Narayanan, 2015).&lt;/p&gt;
&lt;p&gt;With stochastic rounding, 8-bit regrets matched 16-bit. On the same flop the gap reached 0.46% of the pot at 300 iterations, against 0.59% at 16 bits, with EVs within 0.05% of the pot of the 16-bit run. On the river spot it was 0.034% against 0.053%, and even 6-bit regrets got to 0.09%.&lt;/p&gt;
&lt;p&gt;A shared random number generator would make the answer depend on thread scheduling, so the random bit is a hash of the arena&apos;s visit count and the element&apos;s index. Same settings, same bytes on 1, 4 or 8 threads, and a killed run resumes to the same checkpoint.&lt;/p&gt;
&lt;p&gt;Why should it work at all? My intuition, which is a sketch and not a proof: stochastic rounding adds zero-mean, bounded noise to each regret update. Monte Carlo CFR already lives with zero-mean noise from sampling and pays for it with a variance term in the regret bound. Rounding noise should cost the same kind of term. I owe a proper write-up of that.&lt;/p&gt;
&lt;h2&gt;The same trick breaks the strategy sums&lt;/h2&gt;
&lt;p&gt;If 8 bits work for regrets, 8-bit strategy sums seem like the obvious next step. They failed, even with stochastic rounding. Holding the regrets at 16 bits on the river spot, 8-bit sums ended at 4.0% of the pot, 12-bit sums at 0.12%, and 16-bit sums at 0.011%. Worse, the 8-bit run got better until about iteration 100 (0.98%) and then got worse for the rest of the solve.&lt;/p&gt;
&lt;p&gt;I think the difference comes down to feedback. Regrets sit in a closed loop. Regret matching only uses their signs and proportions, so if rounding pushes a regret a bit too high, the next iterations play that action a bit more, watch it do worse, and pull the regret back down. The noise gets corrected.&lt;/p&gt;
&lt;p&gt;Strategy sums are open loop. They&apos;re the solver&apos;s running total of how often it played each action, and nothing reads them during the solve; a comment in our code says &quot;the sums only feed the output, never the dynamics&quot;. Every visit rounds that running total again, and zero-mean noise that nothing corrects does a random walk. In one measurement the drift reached about 5% of the row&apos;s largest value after 600 visits, and that drift goes straight into the strategy you ship.&lt;/p&gt;
&lt;p&gt;My rule of thumb now: stochastic rounding is fine where a feedback loop absorbs the noise, and dangerous where values only pile up.&lt;/p&gt;
&lt;h2&gt;Step 3: don&apos;t store the river average, re-solve it&lt;/h2&gt;
&lt;p&gt;The sums stay at 16 bits, and the river is 99.6% of the tree, so the river&apos;s sums alone were still too big. We stopped keeping them during the run.&lt;/p&gt;
&lt;p&gt;The run trains the river on regrets only. At the end, each river subgame is re-solved for 60 iterations against the flop and turn average strategies, and the result is written back as the river strategy. Re-solving a subgame against a fixed trunk is Burch, Johanson and Bowling&apos;s 2014 decomposition work, with a safe variant from Brown and Sandholm in 2017. We just used it to avoid storing one street&apos;s averaging table.&lt;/p&gt;
&lt;p&gt;It isn&apos;t free. On a smaller K72 rainbow tree at 300 iterations, the re-solved run&apos;s gap was 1.180% of the pot against 0.990% with stored sums. The river itself got better (0.311% against 0.469% for a river-only best response), but a best response on the flop and turn could exploit the mismatch, which is what Burch and colleagues predict for re-solving against a fixed trunk. On our small test spot the result ends near twice the stored gap once converged, and more re-solve iterations don&apos;t close it. A safe variant is built and narrows that at low iteration counts.&lt;/p&gt;
&lt;p&gt;But the full tree fits. On K72 rainbow, 400 iterations reached a gap of 0.587% of the pot with an 8,426 MiB peak footprint (8.2 GiB) and no swap, in 2 h 18 min: 19.5 s per iteration, then about 19 minutes for the re-solve. Ks7s2d two-tone reached 0.874%, and T98 two-tone (Th9s8h), the wettest of the three, reached 0.617%, both at 300 iterations.&lt;/p&gt;
&lt;p&gt;There&apos;s a floor, too. On As2h2d the gap holds at 1.213%, above our 1% bar, and it barely moved between 300 and 400 iterations. A by-street check put 0.71% on the flop and turn and 0.31% on the river, which reads like an 8-bit precision floor on the trunk rather than too few iterations. The lever we have ready is 16-bit regrets on the flop and turn only; they&apos;re a sliver of the memory.&lt;/p&gt;
&lt;p&gt;So I won&apos;t claim &quot;no measurable loss&quot;. The losses are real, we measured them, and we know which knob moves each one.&lt;/p&gt;
&lt;h2&gt;What did not work&lt;/h2&gt;
&lt;p&gt;Each of these was built or measured, then closed.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Lossless compression (zstd, lz4, xz, with sorting and delta coding): 1.5x at best. The low bits of a quantised value are noise, and noise doesn&apos;t compress.&lt;/li&gt;
&lt;li&gt;8-bit strategy sums: 4.0% of the pot with stochastic rounding, 14% with plain rounding.&lt;/li&gt;
&lt;li&gt;Pruning. When you solve every combo at once, a subtree can be skipped only when every combo prunes it, and that held for under 6% of river visits for most of a run.&lt;/li&gt;
&lt;li&gt;Nested parallelism inside each turn task: 18% slower. Eight threads were already 1.5x faster than four; we were out of cores, not scheduling tricks.&lt;/li&gt;
&lt;li&gt;River sums streamed to the SSD, which gets its own story.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We kept the river sums in a file and streamed them through memory every iteration. The footprint held at 8,922 MiB, 8.7 GiB, and a test showed the streamed path producing exactly the same per-hand EVs and best-response gap as the in-memory one, over twelve iterations of a thinned spot. On paper it fit.&lt;/p&gt;
&lt;p&gt;Then the page cache took 4.5 GiB of the 14 GB spill file, and macOS swapped the solver&apos;s own arenas out to make room. We had asked it not to cache the file at all: the run set &lt;code&gt;--spill-cache off&lt;/code&gt;, which asks for &lt;code&gt;F_NOCACHE&lt;/code&gt;, and macOS honours that only for page-aligned IO, which ours was not. The regrets-only phase ran fine, 19.5 seconds an iteration with resident memory near the whole footprint. From the moment averaging started at iteration 201 it fell to 2.5% of that 8.7 GiB footprint, with 5.5 GiB in swap, and iteration 201 still had not finished 438 seconds later when we killed it.&lt;/p&gt;
&lt;p&gt;That 438 seconds is not a clean reading of the design, either. End-to-end tests and a CI job were running beside the averaging phase, and this run&apos;s watchdog predated the pause that would have stopped them. The fix is diagnosed and parked rather than abandoned: page-align the IO, coalesce the traffic, mlock the arenas. The re-solve made it unnecessary at this tree size, and it is still wanted for the full one.&lt;/p&gt;
&lt;p&gt;We only caught it because the Hash Poker Dashboard samples resident share, swap, file cache and disk traffic every 30 seconds. Peak footprint said the run fit. Resident share said it was thrashing.&lt;/p&gt;
&lt;h2&gt;How we know it&apos;s right&lt;/h2&gt;
&lt;p&gt;A memory trick that quietly corrupts the answer is worse than none, so every change climbs a ladder of checks.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Known answers. Kuhn poker reaches its published value of -1/18, and Leduc hold&apos;em matches OpenSpiel&apos;s reference values.&lt;/li&gt;
&lt;li&gt;An exact reference. A slow scalar implementation deals both hands and every runout explicitly, and the fast engine has to match it to 1e-9 per combo on river, turn and flop spots. It runs on every change.&lt;/li&gt;
&lt;li&gt;Determinism. Identical bytes for any thread count, and kill-and-resume gives the same checkpoint.&lt;/li&gt;
&lt;li&gt;Self-measured exploitability. Every solve reports its own gap from a best response under the average strategy, never from stored approximations.&lt;/li&gt;
&lt;li&gt;An independent solver. On K72, with the trees proven identical, our per-combo EVs agree with TexasSolver&apos;s within 0.43% and 0.51% of the pot for the two players.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;One check is still open. On four more boards, three agreed within about 1% per combo. On the monotone board Qh8h4h, hands holding two hearts came out 15.3% and 10.7% of the pot in our favour. That&apos;s structured, not noise. Next we compare our two hand evaluators on all 1.27 million 7-card hands from that board, plus 300,000 heart-heavy random hands, against a brute-force oracle that shares no code with either, and solve monotone spots against the exact reference. Until that&apos;s done, treat our monotone boards as unverified.&lt;/p&gt;
&lt;h2&gt;A live spot&lt;/h2&gt;
&lt;p&gt;As I write, the Mini is working through a pilot of 75 spots. One of them, SB vs BB on Ts7h3h, used about 5.2 GiB in the full-tree configuration. Its gap went from 64.9% of the pot at 50 iterations to 27.7%, 12.4% and 5.7% at 100, 150 and 200. At 400 it stood at 2.14%, because the river was still being played by the solver&apos;s latest guess rather than its averaged strategy. The re-solve fixes exactly that, and brought it to 0.486%.&lt;/p&gt;
&lt;h2&gt;How we worked&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Claude Code (Anthropic&apos;s Claude) was my engineering partner. It wrote and measured the Rust postflop engine and the TypeScript preflop engine with me, ran experiments over SSH on the Mac Mini, and kept the ledger this post comes from.&lt;/li&gt;
&lt;li&gt;Before building, I had a separate AI research agent survey the literature. It recommended rejecting 8-bit regrets because it found no evidence CFR converges with them. We measured anyway, and that measurement is this post. Later, Claude and Grok each answered the same brief on speed independently, and we merged the two answers.&lt;/li&gt;
&lt;li&gt;One M2 Mac Mini with 16 GiB, by choice. No rented machines. A 32 GiB box would run the full tree at 16 bits with no re-solve and no 8-bit noise, on the same code.&lt;/li&gt;
&lt;li&gt;The Hash Poker Dashboard, our own live solver page. It samples the machine every 30 seconds and uses no AI, so it costs nothing to leave running. It&apos;s what caught the SSD run.&lt;/li&gt;
&lt;li&gt;TexasSolver, open source and run as a separate process, and OpenSpiel&apos;s known answers as independent checks. Our engine is clean-room, written from papers.&lt;/li&gt;
&lt;li&gt;GitHub issues as the record of every decision, with the number behind it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What it&apos;s for&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://hash.poker&quot;&gt;Hash.Poker&lt;/a&gt;&apos;s Train mode teaches players that equilibrium strategy and grades their play against it, which takes a lot of solved spots. Owning the solver means no question about what we&apos;re licensed to ship, and a new game variant is a config change. The 8-bit work is what lets a 16 GB desktop do it for the full tree instead of a cut-down one.&lt;/p&gt;
&lt;h2&gt;What I&apos;m not claiming&lt;/h2&gt;
&lt;p&gt;No new algorithm. Not that commercial solvers need more memory: their formats aren&apos;t public, so I compare only to the best published layout. &quot;Poker AI in 16 GB&quot; isn&apos;t a first either; Brown, Sandholm and Amos ran a strong heads-up agent on 4 cores and 16 GB in 2018, using a different technique. And nothing here is &quot;solved&quot; or &quot;GTO&quot;. We compute approximate equilibria of a defined game tree to a stated precision.&lt;/p&gt;
&lt;p&gt;The claim is narrow. CFR regrets survive 8-bit storage with stochastic rounding and stall with plain rounding once the spot is big enough for small updates to matter, while strategy sums don&apos;t survive 8 bits either way. I searched arXiv and the web for quantized CFR, low-precision regret matching and fixed-point CFR and found nobody reporting this. If you know of prior work, tell me and I&apos;ll credit it here.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;M. Zinkevich, M. Johanson, M. Bowling, C. Piccione. &lt;em&gt;Regret Minimization in Games with Incomplete Information.&lt;/em&gt; NeurIPS 2007.&lt;/li&gt;
&lt;li&gt;S. Hart, A. Mas-Colell. &lt;em&gt;A Simple Adaptive Procedure Leading to Correlated Equilibrium.&lt;/em&gt; Econometrica 68(5), 2000.&lt;/li&gt;
&lt;li&gt;N. Brown, T. Sandholm. &lt;em&gt;Solving Imperfect-Information Games via Discounted Regret Minimization.&lt;/em&gt; AAAI 2019. &lt;a href=&quot;https://arxiv.org/abs/1809.04040&quot;&gt;https://arxiv.org/abs/1809.04040&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;N. Burch, M. Johanson, M. Bowling. &lt;em&gt;Solving Imperfect Information Games Using Decomposition.&lt;/em&gt; AAAI 2014. &lt;a href=&quot;https://arxiv.org/abs/1303.4441&quot;&gt;https://arxiv.org/abs/1303.4441&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;N. Brown, T. Sandholm. &lt;em&gt;Safe and Nested Subgame Solving for Imperfect-Information Games.&lt;/em&gt; NeurIPS 2017.&lt;/li&gt;
&lt;li&gt;M. Connolly, N. Higham, T. Mary. &lt;em&gt;Stochastic Rounding and its Probabilistic Backward Error Analysis.&lt;/em&gt; SIAM J. Sci. Comput. 43(1), 2021.&lt;/li&gt;
&lt;li&gt;S. Gupta, A. Agrawal, K. Gopalakrishnan, P. Narayanan. &lt;em&gt;Deep Learning with Limited Numerical Precision.&lt;/em&gt; ICML 2015.&lt;/li&gt;
&lt;li&gt;M. Lanctot, K. Waugh, M. Zinkevich, M. Bowling. &lt;em&gt;Monte Carlo Sampling for Regret Minimization in Extensive Games.&lt;/em&gt; NeurIPS 2009.&lt;/li&gt;
&lt;li&gt;M. Johanson, N. Bard, M. Lanctot, R. Gibson, M. Bowling. &lt;em&gt;Efficient Nash Equilibrium Approximation through Monte Carlo Counterfactual Regret Minimization.&lt;/em&gt; AAMAS 2012.&lt;/li&gt;
&lt;li&gt;O. Tammelin. &lt;em&gt;Solving Large Imperfect Information Games Using CFR+.&lt;/em&gt; 2014. &lt;a href=&quot;https://arxiv.org/abs/1407.5042&quot;&gt;https://arxiv.org/abs/1407.5042&lt;/a&gt; (fixed-point regret storage).&lt;/li&gt;
&lt;li&gt;N. Brown, T. Sandholm, B. Amos. &lt;em&gt;Depth-Limited Solving for Imperfect-Information Games.&lt;/em&gt; NeurIPS 2018. &lt;a href=&quot;https://arxiv.org/abs/1805.08195&quot;&gt;https://arxiv.org/abs/1805.08195&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;b-inary, postflop-solver (README): 16-bit compressed storage, DCFR.&lt;/li&gt;
&lt;li&gt;TexasSolver: &lt;a href=&quot;https://github.com/bupticybee/TexasSolver&quot;&gt;https://github.com/bupticybee/TexasSolver&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;OpenSpiel: &lt;a href=&quot;https://github.com/google-deepmind/open_spiel&quot;&gt;https://github.com/google-deepmind/open_spiel&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;DeucesCracked, &lt;em&gt;How to Use Poker Solvers&lt;/em&gt;: &lt;a href=&quot;https://www.deucescracked.com/poker/strategy/solver-fundamentals&quot;&gt;https://www.deucescracked.com/poker/strategy/solver-fundamentals&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>cfr</category><category>poker</category><category>rust</category><category>memory</category></item><item><title>Skia Graphite under a poker table: two GPU stacks, one breaking API, and no speed claim yet</title><link>https://albertovasquez.com/writing/swapping-a-renderer-under-a-poker-table</link><guid isPermaLink="true">https://albertovasquez.com/writing/swapping-a-renderer-under-a-poker-table</guid><description>We moved our React Native poker table onto Skia Graphite and nothing drew differently. What changed is everything around the pixels: native and web now run two GPU stacks, the one breaking API lives in our hottest path, and a package rename turned a renderer into a release boundary.</description><pubDate>Wed, 07 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;We moved our React Native poker table onto Skia Graphite, and nothing we filmed drew differently. What changed is everything around the pixels.&lt;/p&gt;
&lt;p&gt;Hash.Poker&apos;s table is not a list of rows. It is a Skia canvas: a neon rail, a countdown ring that redraws every frame while a player is on the clock, a winner halo drawn by a shader, chips, a pot pill, and animated gifts one player throws at another. Graphics are the product, and they run on &lt;a href=&quot;https://github.com/wcandillon/react-native-skia&quot;&gt;React Native Skia&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;On 2 October 2026 William Candillon published React Native Skia 3. It is a new package (&lt;code&gt;react-native-skia&lt;/code&gt;, unscoped) on a new GPU backend: Skia Graphite, running on &lt;a href=&quot;https://dawn.googlesource.com/dawn&quot;&gt;Dawn&lt;/a&gt;, Google&apos;s WebGPU implementation, which drives Metal on Apple platforms and Vulkan on Android (&lt;a href=&quot;https://wcandillon.github.io/react-native-skia/docs/getting-started/migration/&quot;&gt;migration guide&lt;/a&gt;). We took version 3.4.4 on 16 October, on iOS, into our 1.14.0 binary.&lt;/p&gt;
&lt;p&gt;This post is what Graphite turned out to mean for our app, and what we have not measured, because the most tempting sentence in a post like this is &quot;and it got faster&quot;, and we cannot write it.&lt;/p&gt;
&lt;p&gt;One piece of mechanics up front: we landed the binary as two commits, Expo SDK 58 and React Native 0.88 first, verified on the simulator on its own, then Graphite, verified again. So &quot;on Graphite&quot; below is compared against a build that already had the new SDK, and the felt results are about the renderer alone.&lt;/p&gt;
&lt;h2&gt;Lesson 1: the felt drew the same, and that needed measuring, not watching&lt;/h2&gt;
&lt;p&gt;The headline result is a negative one. Across six stills of the live table and a 50 second film of a full hand cycle (acting ring, turn and river in flight, showdown reveal, chip collect, winner halo, the &lt;code&gt;+98&lt;/code&gt; rise over the winner, the clean-table hold, the next deal), every beat was present, in order, and looked the way it did on the previous backend.&lt;/p&gt;
&lt;p&gt;&quot;Looked the same&quot; is a weak claim on its own, so we measured the film. Every frame at 30fps, cropped to the felt, reduced to per-frame statistics:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;measure&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;th&gt;what it would have caught&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;near-black frames&lt;/td&gt;
&lt;td&gt;0 of 1,492&lt;/td&gt;
&lt;td&gt;a dropped Metal layer blanking the canvas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;brightness range&lt;/td&gt;
&lt;td&gt;52 to 71&lt;/td&gt;
&lt;td&gt;the felt flashing or dropping out mid-hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;longest identical-frame run&lt;/td&gt;
&lt;td&gt;47 frames (1.57 s)&lt;/td&gt;
&lt;td&gt;a frozen canvas&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The last row is the useful one. Our animation queue deliberately holds a settled table for a moment between hands, and that hold is the longest still stretch in the film. So 1.57 seconds is a ceiling with a known cause: a stalled Graphite recording would have shown up as a run longer than our own intended pause.&lt;/p&gt;
&lt;p&gt;The single most important frame was the countdown ring on a seat that was acting. More on why in lesson 3.&lt;/p&gt;
&lt;p&gt;We also played real hands on the new backend in the dev client with our scenario runner: 25 hands, 110 actions, 3 rebuys, zero reconnects, chips conserved on leave. Not a rendering test, but proof that a canvas re-recorded on every commit did not get in the way of the game. What none of this is: an automated pixel comparison. A person read the frames, and the numbers bound the failure modes we could name.&lt;/p&gt;
&lt;h2&gt;Lesson 2: native and web are now two different GPU stacks&lt;/h2&gt;
&lt;p&gt;This is the one that changes how we work.&lt;/p&gt;
&lt;p&gt;Graphite is native only. On the web, React Native Skia 3 still draws through CanvasKit on WebGL, the same &lt;code&gt;canvaskit-wasm&lt;/code&gt; 0.41.0 that our web loader already pinned. Before the upgrade, native and web were at least the same Skia family drawing through different surfaces. Now the iPhone renders through Skia Graphite, Dawn and Metal, and the browser renders through a WebAssembly build of Skia on WebGL. Same components, same props, entirely different backends.&lt;/p&gt;
&lt;p&gt;That matters because our fastest inner loop is web: the layout lab and headless-Chrome films of the live table. We already knew the web lab lies about native in at least one specific way: on iOS, a &lt;code&gt;&amp;lt;RoundedRect&amp;gt;&lt;/code&gt; whose radius is at least half its width or height throws &lt;code&gt;Invalid properties for rounded rect&lt;/code&gt;, and web CanvasKit draws the same props without complaint. After Graphite, that divergence reads as a symptom: the web lab was never testing the native renderer, and now it does not even share a GPU backend with it.&lt;/p&gt;
&lt;p&gt;Two practical consequences.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The web pin chose our native version.&lt;/strong&gt; The 3.x line moved fast: six minor versions in two weeks, 3.6.1 shipping the day before our cut. The ticket&apos;s original plan, &quot;pin whatever 3.0.x has been out and boring&quot;, had nothing boring left to pick. We chose 3.4.4 for two reasons that have nothing to do with how the felt looks on a phone. It keeps &lt;code&gt;canvaskit-wasm&lt;/code&gt; at 0.41.0, so the web CDN pin did not have to move. And its native Apple binary is version 154.3.0, the same one the newest releases ship, so it is the most-exercised binary of the candidates even though the JavaScript around it is older. In a young line, the native binary a version ships with is a better signal than the version&apos;s age.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The web constraints did not move.&lt;/strong&gt; The browser&apos;s limit of roughly 16 live WebGL contexts still applies, so the design rules it forced on us stay: one shared canvas per button cluster, and chips drawn as plain React Native views on web because a canvas per chip blows the cap. Graphite&apos;s improvements are native improvements. Anything we design around them still has to survive the web build.&lt;/p&gt;
&lt;p&gt;The rule we took from this: &lt;strong&gt;a web film is evidence about choreography and layout, never about the native renderer.&lt;/strong&gt; For anything that touches how Skia draws, the simulator is the test.&lt;/p&gt;
&lt;h2&gt;Lesson 3: the one breaking API sits in the hottest path&lt;/h2&gt;
&lt;p&gt;The migration guide says &quot;the drawing API is the same as in v2: components, hooks, shaders, and the imperative &lt;code&gt;Skia&lt;/code&gt; API all work as before.&quot; For declarative components that was true for us. For imperative paths it was not: in Skia 3, &lt;code&gt;SkPath&lt;/code&gt; is immutable. Every verb moved to a new &lt;code&gt;SkPathBuilder&lt;/code&gt;, and you call &lt;code&gt;.build()&lt;/code&gt; to freeze the result. Counted in the published type definitions of &lt;code&gt;react-native-skia@3.4.4&lt;/code&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;method&lt;/th&gt;
&lt;th&gt;on &lt;code&gt;SkPath&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;on &lt;code&gt;SkPathBuilder&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;moveTo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lineTo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;addArc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;addCircle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;close&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The guide does not mention &lt;code&gt;SkPath&lt;/code&gt;, &lt;code&gt;PathBuilder&lt;/code&gt; or &lt;code&gt;Path.Make&lt;/code&gt; at all; we searched it for each. The fix is mechanical:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const path = Skia.PathBuilder.Make();
path.addArc(rect, -90, 360 * progress);
return path.build();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The compiler finds every site, because a method that no longer exists is a type error. Twelve of our files import Skia. Three used the mutable path API: the lobby&apos;s hero frames, the long-press stats ring, and the countdown ring.&lt;/p&gt;
&lt;p&gt;The interesting part is which three. The lobby frames and the stats ring are built once. The countdown ring rebuilds its arc &lt;strong&gt;every frame&lt;/strong&gt;, inside a Reanimated worklet on the UI runtime, for the entire time a player is on the clock. The only code Graphite forced us to rewrite is the code that runs most often and on the thread whose job changed most. That is why the frame of a seat carrying a live acting ring was the frame that mattered most in lesson 1.&lt;/p&gt;
&lt;p&gt;There was a better fix available, and we did not take it. A performance audit we ran two weeks earlier had already flagged that the ring allocates paths on every frame, and recommended building the circle once and trimming it with the &lt;code&gt;Path&lt;/code&gt; &lt;code&gt;end&lt;/code&gt; prop, which exists in 3.x. Our own decision ticket for Graphite said to do that fix inside the upgrade rather than porting the per-frame build. We reversed that on the day: trimming a static path is not guaranteed to look identical to rebuilding an arc, and a change to how the dial looks is a product decision that had not been made. So the ring is a line-for-line port, still allocating per frame, and the better fix waits for a look review. &lt;strong&gt;An API break invites a refactor. Keep the refactor out of the upgrade when it changes what players see&lt;/strong&gt;, or a felt that looks different afterwards has two possible causes again.&lt;/p&gt;
&lt;h2&gt;Lesson 4: proving the backend shipped meant reading the right binary&lt;/h2&gt;
&lt;p&gt;Our first check that Graphite was actually in the build came back negative. It was a false negative, and an instructive one.&lt;/p&gt;
&lt;p&gt;In our debug simulator build, &lt;code&gt;pkrrDev.app/pkrrDev&lt;/code&gt; is a 121K launcher stub. The app&apos;s code, Skia included, lives in a separate &lt;code&gt;pkrrDev.debug.dylib&lt;/code&gt; of about 223 MB. A &lt;code&gt;strings&lt;/code&gt; search of the stub finds no Skia at all. The dylib is where the answer is:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;symbol&lt;/th&gt;
&lt;th&gt;count in the debug dylib&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RNSkia&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3,710&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;graphite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;266&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;skgpu&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;260&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SkPathBuilder&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;208&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Dawn is linked into that same dylib rather than shipped as a separate framework: the app&apos;s &lt;code&gt;Frameworks&lt;/code&gt; folder has no Dawn, Graphite or WebGPU entry, while the dylib holds hundreds of Dawn strings.&lt;/p&gt;
&lt;p&gt;The general point: &lt;strong&gt;when you verify that a native backend shipped, find out where your build system actually puts code before you search it.&lt;/strong&gt; &quot;No matches&quot; from the wrong file reads exactly like &quot;not there&quot;.&lt;/p&gt;
&lt;h2&gt;Lesson 5: a package rename turned the renderer into a release boundary&lt;/h2&gt;
&lt;p&gt;We ship JavaScript updates over the air between binaries, routed to the binary generation they were built for. A dependency upgrade usually does not affect that. This one did.&lt;/p&gt;
&lt;p&gt;After the Graphite commit, our JavaScript imports &lt;code&gt;react-native-skia&lt;/code&gt;. The previous binary, 1.13.0, links the old scoped &lt;code&gt;@shopify/react-native-skia&lt;/code&gt;. So from that commit onward, nothing on &lt;code&gt;main&lt;/code&gt; can be sent to 1.13 testers: the JavaScript would ask for a native module the binary does not have. We cut Graphite into a new generation, 1.14.0, on its own update channel, kept the 1.13 build installable as a known-good comparison without Graphite, and noted that any 1.13 hotfix has to branch from the commit before the rename.&lt;/p&gt;
&lt;p&gt;That is about how we distribute, not about Skia. But anyone running over-the-air updates should know that &lt;strong&gt;Skia 3 is not a drop-in dependency bump. The rename makes it a binary-generation event&lt;/strong&gt;, whether or not a single pixel changes.&lt;/p&gt;
&lt;p&gt;A smaller packaging change pointed the right way. Skia 2 downloaded its native binaries in a postinstall script, which pnpm blocks by default, so it needed an entry in our install-scripts allow-list, and a suppressed script there had produced confusing native build failures before. Skia 3 ships its binaries as ordinary npm dependencies (&lt;code&gt;react-native-skia-graphite-apple-ios&lt;/code&gt; and friends) with no postinstall. The allow-list is now empty.&lt;/p&gt;
&lt;h2&gt;Lesson 6: a regression check is not a removal check&lt;/h2&gt;
&lt;p&gt;One of our workarounds lives right next to the canvas. Our gifts animate as a clip and then swap to a still image, and that swap used to flash a blank frame on 4 to 6 of every 10 sends. The fix mounts the still hidden ahead of the swap and reveals it in the same commit that removes the clip. A new renderer could plausibly have disturbed that timing, so we filmed it: 15 sends across four gifts at 60fps, measuring the gift&apos;s painted width on every frame through the swap. No blank frame in any run.&lt;/p&gt;
&lt;p&gt;It is tempting to read that as &quot;the workaround is no longer needed&quot;. But every one of those runs had the workaround in place: they prove Graphite did not regress the handoff, not that the mitigation can go. That second question needs runs with it removed, a positive control proving the measurement can still go red, and more runs, since at the historical rate of about 20% ten clean runs happen one time in nine by luck. It is its own ticket.&lt;/p&gt;
&lt;h2&gt;What we are not claiming&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;No performance numbers.&lt;/strong&gt; We took none on Graphite. The architectural change is real and documented: in v2, &lt;code&gt;&amp;lt;Canvas&amp;gt;&lt;/code&gt; drew its frames on the UI thread; in v3 the scene is recorded once per React commit and a native thread pool replays it, while the UI thread only applies animated values. That sounds like a win for a per-frame ring and a shader halo.&lt;/p&gt;
&lt;p&gt;But our audit two weeks earlier found that the stutters players notice are a pacing problem, not a render-speed one. The felt deliberately plays a median 5 to 8 seconds behind the server during busy hands, and the skips are the queue&apos;s catch-up rules doing their job. Taking drawing off the UI thread does not fix a queue that is behind on purpose. So, honestly: &lt;strong&gt;we adopted a faster renderer for reasons that were not speed.&lt;/strong&gt; We took it because v2 stops at 2.14 and v3 is where the work continues.&lt;/p&gt;
&lt;p&gt;The new architecture also changes &lt;em&gt;which&lt;/em&gt; of our performance work matters, in both directions, and none of it is measured yet:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Re-renders now cost a recording.&lt;/strong&gt; If a lobby update re-renders every seat&apos;s canvas, each one becomes a fresh native recording. Skipping needless re-renders was already on our list; Graphite raises the price of not doing it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Our old profiles describe a binary we no longer ship.&lt;/strong&gt; A Ganesh profile that says &quot;the UI thread is busy in Skia&quot; says nothing about a backend that replays off the UI thread. The canvas-consolidation question we had open has to be re-answered from a new profile, not carried over.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One constraint may have gone away.&lt;/strong&gt; Under Ganesh a GPU texture belonged to the thread that created it. Graphite&apos;s documentation says GPU-backed images can be drawn from any canvas and any worklet runtime. That reopens a design question in our planned sprite-atlas player for gifts, and we have not tested the answer.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;No binary-size number either.&lt;/strong&gt; The debug simulator build with Graphite is 442 MB unpacked. An older debug build we still have, on the previous SDK and Skia 2, is 385 MB. That gap mixes an SDK upgrade with a renderer change, and a debug build says little about the App Store download, so it is not a Graphite number and we are not quoting one. The release artifact&apos;s size is still unread.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Not tested:&lt;/strong&gt; Android (we do not ship it; Graphite needs API 26 and Vulkan), a real device for the new generation, and the long-press stats ring on screen, which rests on sharing the countdown ring&apos;s call shape.&lt;/p&gt;
&lt;h2&gt;If your app draws with Skia and you are about to take Graphite&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Search for &lt;code&gt;Path.Make()&lt;/code&gt; first. It is the size of your migration, and the guide will not tell you. Then check which of those paths are built per frame: that is where to point the camera.&lt;/li&gt;
&lt;li&gt;Treat web and native as &lt;strong&gt;different renderers&lt;/strong&gt; from now on. A browser film proves layout and choreography, not drawing.&lt;/li&gt;
&lt;li&gt;Pick the version by its &lt;strong&gt;native binary&lt;/strong&gt; and by what it forces on your web pin, not by how long it has been out.&lt;/li&gt;
&lt;li&gt;Prove the backend shipped by searching &lt;strong&gt;the file that holds your code&lt;/strong&gt;, not the app&apos;s main executable.&lt;/li&gt;
&lt;li&gt;If you ship over-the-air updates, plan the rename as a &lt;strong&gt;new binary generation&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Name the failure modes before you film (a blanked canvas, a frozen frame) and measure for them.&lt;/li&gt;
&lt;li&gt;Do not write &quot;faster&quot; until you have re-profiled on the new backend. Our honest position is that we do not know yet.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://wcandillon.github.io/react-native-skia/docs/getting-started/migration/&quot;&gt;React Native Skia v3 migration guide&lt;/a&gt; - the package rename, platform requirements, removed &lt;code&gt;Canvas&lt;/code&gt; props, rendering off the JS and UI threads, and textures usable from any thread.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/wcandillon/react-native-skia&quot;&gt;wcandillon/react-native-skia&lt;/a&gt; - the v3 repository.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/Shopify/react-native-skia&quot;&gt;Shopify/react-native-skia&lt;/a&gt; - the v2 line, which stops at 2.14.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://dawn.googlesource.com/dawn&quot;&gt;Dawn&lt;/a&gt; - Google&apos;s WebGPU implementation, which Graphite runs on.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SkPath&lt;/code&gt; and &lt;code&gt;SkPathBuilder&lt;/code&gt; method counts read from the published type definitions of &lt;code&gt;react-native-skia@3.4.4&lt;/code&gt; (&lt;code&gt;lib/typescript/src/skia/types/Path/&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>react-native</category><category>skia</category><category>poker</category><category>expo</category><category>testing</category></item><item><title>Our poker engine runs inside the database: four months on SpacetimeDB</title><link>https://albertovasquez.com/writing/our-poker-engine-runs-inside-the-database</link><guid isPermaLink="true">https://albertovasquez.com/writing/our-poker-engine-runs-inside-the-database</guid><description>95 tables, 92 reducers and 27 views, with the rules engine running as TypeScript inside the database. What the platform gave us, what we had to build ourselves, and why the write lock — not the table count — is the ceiling.</description><pubDate>Tue, 06 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Four months in, the platform gave us transactional fan-out and privacy by construction, and left us owning reconnect, presence, retention and the shuffle.&lt;/p&gt;
&lt;p&gt;In June we picked &lt;a href=&quot;https://spacetimedb.com&quot;&gt;SpacetimeDB&lt;/a&gt; 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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;The bet&lt;/h2&gt;
&lt;p&gt;Server-authoritative poker needs three things. Hidden state that never leaves the server, except each player&apos;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Two decisions made it stick. The first is that nothing above our &lt;code&gt;PokerClient&lt;/code&gt; 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.&lt;/p&gt;
&lt;h2&gt;The engine runs inside the database&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;What that module looks like today:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;In the module&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tables&lt;/td&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reducers&lt;/td&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Views&lt;/td&gt;
&lt;td&gt;22 per-sender, 5 anonymous&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Row-level-security filters&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduled reducers (timers)&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lines in the module entry file&lt;/td&gt;
&lt;td&gt;15,445&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Three things about running TypeScript in there were better than expected. &lt;code&gt;bigint&lt;/code&gt; chip amounts round-trip exactly through &lt;code&gt;u64&lt;/code&gt; columns, including values above &lt;code&gt;Number.MAX_SAFE_INTEGER&lt;/code&gt;, so money never touches a float. The runtime removes the impure escape hatches: there is no stdlib clock and &lt;code&gt;Math.random&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;Privacy by construction, which is not the same as privacy by default&lt;/h2&gt;
&lt;p&gt;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: &lt;code&gt;public_table&lt;/code&gt;, &lt;code&gt;public_seat&lt;/code&gt;, a hand-result event stream.&lt;/p&gt;
&lt;p&gt;Your own cards come from a per-sender view. Those views find the caller&apos;s seat through index lookups on the caller&apos;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&apos;s copy re-runs on every write to it.&lt;/p&gt;
&lt;p&gt;We learned that lesson once per view. &lt;code&gt;tournament_summary&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;What we still had to build&lt;/h2&gt;
&lt;p&gt;Here is the part nobody puts in the pitch deck.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reconnect, four layers of it.&lt;/strong&gt; A module publish can leave a running client unable to decode rows. The SDK&apos;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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Presence, because disconnect is slow.&lt;/strong&gt; We measured how long a silent network drop takes to reach the module&apos;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&apos;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 &quot;away&quot; 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Retention, because storage is a ratchet.&lt;/strong&gt; Our audit tables (every hand&apos;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&apos;s rows 30 days after it ends, 500 hands per run.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The shuffle.&lt;/strong&gt; This one is the most interesting, and the least comfortable. The module SDK gives reducers a &lt;code&gt;ctx.random&lt;/code&gt;, and our ADR said it was &quot;host-seeded, deterministic per reducer call&quot;. 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: &lt;code&gt;globalThis.crypto&lt;/code&gt; 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. &lt;code&gt;ctx.random&lt;/code&gt; kept bot pacing and tournament seating.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;fill&lt;/code&gt; as being &quot;like &lt;code&gt;crypto.getRandomValues()&lt;/code&gt;&quot;, 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&apos;s type definitions are a claim, not evidence.&lt;/p&gt;
&lt;h2&gt;The schema is a one-way contract, and mobile makes it worse&lt;/h2&gt;
&lt;p&gt;This is the hardest constraint of the whole stack, and it is the one I would warn a new team about first.&lt;/p&gt;
&lt;p&gt;SpacetimeDB stores rows as typed BSATN and auto-migrates only an additive subset. There is no in-place &lt;code&gt;ALTER TABLE&lt;/code&gt;. The only way to force a forbidden change is &lt;code&gt;--delete-data&lt;/code&gt;, which wipes the entire database: accounts, chips, history, everything.&lt;/p&gt;
&lt;p&gt;Adding a new table or view is free, with no disconnect. Changing a view&apos;s body is free. Adding an index or changing reducers is free. But &lt;strong&gt;appending a column to a table a shipped client subscribes to disconnects every connected client&lt;/strong&gt;, 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.&lt;/p&gt;
&lt;p&gt;So the rules we live by now are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Our publish command for the live databases deliberately leaves the &quot;break clients&quot; confirmation out of its auto-yes list, so that prompt reads end-of-file and the publish aborts before anything changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A checker (&lt;code&gt;pnpm ota:subscriptions&lt;/code&gt;) 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.&lt;/p&gt;
&lt;h2&gt;React Native: four things you only find by shipping&lt;/h2&gt;
&lt;p&gt;The JavaScript SDK works on Hermes, with four adjustments.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;One subprotocol.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No gzip.&lt;/strong&gt; Hermes has no &lt;code&gt;DecompressionStream&lt;/code&gt;, 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Measure egress at the client.&lt;/strong&gt; The server&apos;s own byte counter undercounted what our clients actually received by up to 5.9x when several clients shared a table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resolve the auth token per connect attempt, not once.&lt;/strong&gt; Our access tokens live 15 minutes, and the SpacetimeDB credential &lt;em&gt;is&lt;/em&gt; that token. A transport that remembers its construction-time token rejects forever after a backgrounded drop.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;And one platform asymmetry worth planning for: native has no global &lt;code&gt;error&lt;/code&gt; / &lt;code&gt;unhandledrejection&lt;/code&gt; 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.&lt;/p&gt;
&lt;h2&gt;Local development is the quiet win&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;spacetime start&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;publish -c&lt;/code&gt; deletes the data, which is fine there and categorically forbidden on the real one.&lt;/p&gt;
&lt;p&gt;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 &lt;strong&gt;every persisted hand&lt;/strong&gt; through the pure engine. A clean run proves the entire live history obeyed no-limit hold&apos;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.&lt;/p&gt;
&lt;p&gt;Two scars from that tooling. The checker used to buffer the whole result set through one JSON string, which exceeded V8&apos;s roughly 512 MB string cap past about 12,000 rows and then &lt;strong&gt;failed open&lt;/strong&gt;, looking exactly like a passing run. And the SQL endpoint speaks a narrow dialect: no scalar subqueries, no &lt;code&gt;SUM()&lt;/code&gt;, 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.&lt;/p&gt;
&lt;h2&gt;Production, and what it costs&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;The finding that changed our design: the free tier pauses a database only after &lt;strong&gt;no client connections and no reducer calls&lt;/strong&gt;. 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&apos;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;/v1/health&lt;/code&gt;, and we read it before trusting any measurement.&lt;/p&gt;
&lt;h2&gt;Could this run 1,000 tables?&lt;/h2&gt;
&lt;p&gt;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&apos;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.&lt;/p&gt;
&lt;p&gt;We measured that properly in September, and it is long enough to be its own piece: &lt;a href=&quot;https://albertovasquez.com/writing/one-write-lock-at-1000-poker-tables&quot;&gt;One write lock: measuring SpacetimeDB&apos;s subscription cost at 1,000 tables&lt;/a&gt; has the numbers, the remedy, and the thing I guessed wrong.&lt;/p&gt;
&lt;h2&gt;What we have not used yet&lt;/h2&gt;
&lt;p&gt;Four months in, our map of the platform has obvious blank regions.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Row-level security.&lt;/strong&gt; We have one filter and 22 hand-written per-sender views. Some of those are genuinely computed projections, but some are just &quot;the caller&apos;s rows&quot;, which is what RLS is for.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Light mode and confirmed reads.&lt;/strong&gt; Our socket opener passes both through and the app sets neither. Light mode in particular looks like free egress on a phone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The client-side cache as a prediction surface.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;More than one database.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A Rust module for one hot path.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;Would I pick it again&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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&apos;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.&lt;/p&gt;
</content:encoded><category>spacetimedb</category><category>poker</category><category>typescript</category><category>architecture</category></item></channel></rss>