Poker Profiles
Poker Profiles is a web application for players who study their own game. A hand is recorded action by action while it happens, replayed afterwards step by step or on a timer, and shared by link; around that sit opponent profiles with range grids and bankroll sessions tracked over time. I built the web application. Most of the work is not in the screens — it is in making a hand's money add up identically whether it is being recorded, reloaded, replayed, or stepped backward.
At a glance
- Role: Sole developer of the web application.
- Stage: Live product, with a companion mobile app that owns subscription purchase.
- Core constraint: Keep stacks and pots identical across recording, reload, replay, and undo.
- Strongest evidence: An incremental-amount move model, fully reversible actions, and aligned starting state for recording and replay.
Context
Recording a poker hand is data entry against rules. A table is two to ten players with positions rotating around the hero's seat, blinds and antes posted before anyone acts, then preflop, flop, turn, and river, with every player folding, checking, calling, betting, raising, or moving all in. Each of those actions moves money out of a stack and into a pot, changes whose turn it is, and can end the round or the hand outright.
That would be tractable if a hand were only ever recorded once. It is not. The same hand has to be resumable if the user comes back to it, replayable from the beginning, and steppable in reverse. Each of those paths reads the same move list and has to arrive at the same stacks and the same pot at every point along it. A number computed one way during recording and another way during replay is not a subtle inconsistency — it is a stack showing the wrong amount in the middle of a hand the user watched happen.
My role
I set up the project and built the web application end to end: the game state model, recording, replay and sharing, opponent profiles and ranges, bankroll analytics, authentication, and the subscription gate on the dashboard. A companion mobile application sits alongside the web product and owns subscription purchase.
Architecture
The dashboard is a Next.js application over Firebase, and it holds two kinds of state that behave nothing alike. Hands, opponent profiles, and bankroll sessions are server state: fetched from Firestore, cached, and invalidated through TanStack Query. The hand currently being recorded is not that. It is a local domain model mutated dozens of times in a few minutes, where every mutation depends on the one before it.
Recording is local, and persistence is a checkpoint. Validating an action, deducting from the stack, adding to the pot, advancing the turn, and transitioning the round all happen in memory through Zustand with Immer, and the hand is written to Firestore when recording stops or completes. There is no request per action, so the interface responds at the speed of a tap. The tradeoff is deliberate and worth naming: correctness of the hand lives in client code, which is appropriate because this is one person recording their own hand rather than a table being synchronized between players.
Billing belongs to the mobile application. The dashboard sells nothing. It checks the signed-in user's entitlement through RevenueCat and directs anyone without one to the app stores. That keeps a payment flow out of the web application entirely, in exchange for making the identity mapping between the authenticated user and the entitlement record a dependency that has to be right.
Underneath both sits the turn model. Folded seats are skipped, the count of players still active drives whether a betting round is complete, and the hand ends when one active player remains. Position labels — dealer, small blind, big blind, under the gun — are mapped for each table size and rotated around the hero's seat rather than recalculated per hand.
Stack
Client — Next.js 14 · React 18 · TypeScript · Zustand · Immer
Data — Firebase Authentication · Firestore · Firebase Storage · TanStack Query
Forms and analytics — Zod · React Hook Form · Recharts
Platform — RevenueCat · Vercel
Engineering challenges
The same raise meaning two different numbers
The problem. Stacks and pots kept disagreeing with each other across recording, reload, and replay. Correcting one path would leave another wrong, and the same class of fault kept resurfacing in a different place.
Why it was hard. A raise in poker is stated as a total, not as a payment. A player who already has ten in front of them and raises to fifty pays forty. The number the interface shows and the number the stack loses are different numbers, and the difference depends on what that player had already committed in the current round — which is itself a product of blinds, antes, earlier calls, and all-ins that could not cover a full amount. Any code path that re-derives the deduction has to re-derive all of that context correctly, and there were several such paths: applying a move live, rehydrating a saved hand, replaying from the start, and stepping backward through one.
The decision. Stop recomputing the deduction. Record it.
Implementation. Each move carries the incremental amount it actually removed from the player's stack alongside the total it represents. Recording computes that once, at the moment the action is applied, when the player's commitment for the current round is known with certainty. Every other consumer — persistence, reload, replay, reverse stepping — reads it rather than reconstructing the context that produced it. Blind and ante initialization was reconciled at the same time so recording and replay begin from the same post-blind state.
Outcome. The arithmetic became a property of the move instead of something several call sites each had to derive correctly and independently. That is what turned a recurring class of stack and pot disagreements into something addressable at the model rather than patchable at each site.
Two ways into the same hand
The problem. Recording and replay both have to put a hand into a known state before anything else can happen, and they need different things from that. Recording may resume a hand mid-flight from its saved current state. Replay has to go back to the beginning — blinds and antes posted, no action taken — and apply the recorded moves forward from there.
Why it was hard. Because the entry conditions genuinely differ, the obvious implementation is two hydration paths, and that is what it became. It works until the definition of "the state after blinds and antes are posted" has to change, at which point there are two implementations of one rule and only one of them gets updated. Big-blind ante is exactly that kind of rule: it shifts the ante into the big blind's own posting, so both the opening pot and one player's starting stack differ from the standard case.
The decision. Treat the post-blind starting state as a single definition both paths owe agreement to, and correct the drift where they diverged from it instead of compensating downstream.
Implementation. The two hydration paths stay separate, because their inputs really are different, but the initialization they perform was aligned: the same blind and ante handling, the same starting stacks, the same opening pot. Replay then consumes recorded moves forward, applying the incremental amount each one carries, rather than re-deriving what every action should have cost on its way past.
Outcome. Replay reconstructs the stacks and pot the recorder saw, because it starts from the same place and applies the same numbers instead of a second opinion about them.
Every action has to be reversible
The problem. Recording has undo. Replay has previous and next. They are the same requirement: take an applied action back out of the state without rebuilding the hand from scratch.
Why it was hard. One poker action touches far more than one value. A raise changes the acting player's stack, the pot, the amount others have to match, the turn position, the move history, and that player's membership in the sets tracking who has checked, who has bet, and who has folded. Reversing it means restoring all of those, in an order that does not corrupt the ones still to come. A fold is worse than a bet, because it also changes who is still in the hand and therefore whether the round would have ended at all. Replay carries the same problem plus one more: forward stepping consumes from the moves still to come while backward stepping needs the moves already played, so two sequences have to stay in agreement about where the hand currently is.
The decision. Make reversal explicit for every piece of state a move touches, rather than leaning on a rebuild from the beginning.
Implementation. Undo restores the player's balance and pot contribution, removes the move from history, resets the last action and the turn position, and corrects membership in the checked, bet, and folded tracking sets. Replay keeps pending and played moves as two coordinated sequences and moves entries between them as the user steps, so arriving at a point going forward and arriving at it going back produce the same state. Playback runs manually or on a timer at several speeds.
Outcome. A hand can be stepped through in both directions, and a mis-entered action can be taken back mid-recording without discarding the hand — which is what makes recording during live play practical rather than theoretical.
Outcome
Poker Profiles works as a product. Hands are recorded action by action for tables of two to ten, persisted to Firestore, replayed step by step or on a timer, and shared by link. Around that sit the things a studying player needs between hands: opponent profiles with locations, tags, playing styles, notes, and range grids, and bankroll sessions with buy-ins, rebuys, cash-outs, net profit, and trend charts, behind Firebase authentication and an entitlement check that keeps purchasing in the mobile application where it belongs.
The engineering result is the incremental-amount model. Making each move carry what it actually moved turned stack consistency from something every path had to derive correctly and identically into something they all read from one place. Recording, reload, replay, and reverse stepping agree because they are no longer forming independent opinions about the same number.
The clearest lesson is about where domain logic belongs. What a raise costs, what state exists after blinds and antes, when a betting round ends — those are rules of the game, and they belong in one engine every surface calls rather than being implemented once per entry point. Recording and replay grew as separate implementations because they enter a hand from different states, and that is precisely where the disagreements came from. The incremental-amount model is that principle applied to the rule that was costing the most; extending it to the rest is the change I would make next.