Files
Pete/pete_games_plan.md
prosolis 79c857023f games: a table of bots you have to beat to the last card
UNO, played for chips. You stake once, sit down against one to three bots,
and going out first pays the table: 2.2x heads up, 3.6x against a full house.
Anybody else going out first takes the stake. The table size is the tier,
because it is the only dial UNO has.

The bots move inside ApplyMove. A game with opponents is normally where you
reach for a socket, and the plan says solo UNO must not — so one request plays
your move and every bot turn behind it, and hands back the whole lap as a
script the felt plays in order.

The RNG is in the state rather than an argument to it: the bots choose and a
spent deck reshuffles, so the engine needs randomness mid-game, and there is no
generator alive across requests to pass in. The seed rides in the state and each
step derives its own. The game still replays exactly as it fell.

The zero value of Color is Wild, and that is the whole point of it: a wild
played with the colour field missing from the JSON must be refused, not
quietly played as a red one. It was red for an hour.

The browser never sees a bot's card — not the deck, not a hand, not the face of
a card a bot drew, which is most of the deck. Seats cross the wire as a name and
a count.

The multiples are measured, not guessed: playing the first legal card you hold
wins 43/32/27% of the time against these bots, so the tiers price that to lose
about 8% a game and leave good play worth roughly the house's edge.

PeteFX.flyNode is the throw with the chip taken out of it, so a card can be
thrown across the felt the same way. fly() is now that with a chip in it.

Not yet driven in a browser, which in this room means not yet finished.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 07:07:17 -07:00

45 KiB
Raw Blame History

Pete Games — games.parodia.dev

A web casino/arcade on Pete, authenticated by Authentik, playing for gogobee euros. Blackjack, Texas Hold'em, UNO (normal + no-mercy), Hangman, Trivia.

Companion to gogobee_mischief_plan.md, which already established the web↔game seam. This plan reuses that seam wholesale and does not invent a second one.


0. Progress — last updated 2026-07-14

A multi-session build. This section is the handover; read it before anything else.

Decisions taken (these close §9's open questions)

  • Chips are 1:1 with euros. No second denomination.
  • Session buy-in cap: €10,000, enforced against chips held plus buy-ins still in flight, so it can't be cleared by firing several requests at once.
  • A house rake, 5% in blackjack's DefaultRules, taken from winnings only — never the stake. A push returns the bet untouched; a loss is never charged a fee.
  • The site shares Pete's design, not Pete's shell. (Revised 2026-07-13 — this replaces the earlier "the site must look like Pete", which meant layout.html itself.) The casino is its own place. It takes the design language — Fredoka/ Nunito, the four palette vars, rounded-3xl, shadow-pete, the bubbly weight of everything — and takes none of the furniture: no Pete avatar, no channel nav, no search, no reader, no settings, no weather canvas, no PWA. It has its own layout (games_layout.html), its own header, its own footer, its own scripts. Still not an SPA; still server-rendered html/template.
  • It has two names, on a clock. Casinopolis by day, Casino Night Zone from six in the evening — palette, felt and the sign over the door all change together. This is the news app's phase system pointed at a joke: one data-room attribute, two palette blocks, and a rule shared between roomAt() in Go (first paint) and the same rule in JS (the player's own clock, so a player abroad gets their own evening).
  • Dealing is animated. Cards visibly dealt and flipped, chips that move. This is a requirement, not polish to drop when the clock runs out.

Done

  • Phase 0 — euro idempotency (gogobee). euro_transactions.external_id + a partial unique index, and CreditIdem/DebitIdem in internal/plugin/euro.go: balance mutation and transaction log in one tx, keyed by the escrow GUID. A replay reports success without moving money again; a rejection writes nothing, so the same GUID stays retryable once the player is good for it. Six tests, including eight goroutines racing one GUID. (gogobee ab2bcf0)

  • pete/internal/games/cards — the shared deck gogobee never had. RNG is threaded, never the package global, so a hand is reproducible from its seed. (pete 8310b30)

  • pete/internal/games/blackjack — pure reducer, ApplyMove(state, move) (state, []Event, error), where an error means the move was illegal and nothing else. State is a plain value: it serializes, so a hand survives a redeploy, and it replays. Six decks, 3:2, dealer hits soft 17, plus the rake. (pete 8310b30)

  • pete/internal/storage/games.go — the euro/chip border. game_chips, game_escrow, game_hands. Chips appear only once gogobee confirms it took the euros; chips are destroyed the moment a cash-out opens (so they can't be bet while their euros are in flight) and come back if the credit fails. Table cap, 30-minute reaper, per-hand audit log with seeds. 17 tests. (pete f9a98f7)

  • The wire protocol. Pete serves GET /api/games/escrow/pending, POST …/claim, POST …/settled (internal/web/games.go), bearer-authed on the adventure ingest token. gogobee polls every 3s (internal/plugin/pete_games.go), claims a row, calls DebitIdem/CreditIdem against the escrow GUID, and pushes the verdict back through pete_emit_queue — which grew a path column so escrow verdicts ride the same durable queue as adventure facts rather than getting a second one. peteclient.Flush sends the verdict immediately instead of waiting out the 15s sender tick, because a player is watching a spinner. A row re-offered after a gogobee crash replays as a no-op: 13 tests across both repos, including a fake Pete that offers the same row three times and a player who is charged once.

  • Identity. preferred_username now rides in the signed session, and SessionUser.MatrixUser(server) maps it to @user:parodia.dev. The session cookie takes an opt-in web.auth.cookie_domain, so a sign-in on news is a sign-in on games; the OAuth round-trip cookie deliberately stays host-only, and the redirect_uri is derived per-request so a login that starts on games comes back to games. A Host we don't own is never echoed into a redirect. (pete cb84e1d)

  • Blackjack, playable end to end. game_live_hands (the hand in progress, engine state and all, so a redeploy mid-hand is survivable), the session-authed play surface (internal/web/games_play.go), the lobby and table pages, and the dealing animation. Driven in a real browser: chips staked before the deal, hole card withheld from the payload until the reveal, payout settled back into the stack.

  • The casino moved out. Its own layout (games_layout.html), parsed as its own template set alongside the news one; gamesPage no longer embeds the news pageData, which is what stops the old furniture drifting back one convenient field at a time. Two rooms on a clock (above), the felt reupholstered from the room's vars, and a house mark that is a honeycomb chip rather than a face.

  • The cards are cards. Corner indices in both corners (the bottom one upside down, as printed), pips laid out on the three-by-seven grid a real deck uses, bottom-half pips inverted, courts as a letter with the suit over each shoulder, and a screen-reader label that says "Queen of hearts" instead of "Q♥".

  • The money moves. The felt grew the two things it was missing: a bet spot in front of you and the house's rack beside the shoe, so every chip on the table is always travelling between one of those and the other. A bet is built by throwing chips onto the spot (the chip you clicked is the chip that flies); the stake sits there through the hand; the house pays out of its rack into the spot; the whole pile is then swept back to your pile. A loss goes to the rack and doesn't come back. casino-fx.js is the shared engine — fly/flyMany (WAAPI, on an arc, out of a fixed overlay so nothing clips them), chipsFor (an amount broken into the fewest chips, capped at what's worth watching), burst, count.

    Two rules hold it together, and both are load-bearing:

    1. The number under the pile is a readout of the pile, never the other way round. So the bet starts at zero rather than at a default nobody put down, and a settled hand puts your stake back on the spot as a standing bet — otherwise the panel prints "your bet: 300" over an empty circle.
    2. The chip bar does not move until the chips that justify it have landed. On a live hand the money applies immediately (your stake left your pile and is visibly on the spot); on a settling hand play() holds the apply until the payout has swept home. A counter that pays you before the dealer turns over is a counter that has told you the ending.

    Also: cards land with weight (overshoot, a shadow that takes the hit, a degree or two of resting tilt each), the dealer takes a beat before drawing out, and a natural gets confetti — the only thing in the room that does.

  • A way to actually look at it. internal/web/devcasino_test.go is the casino on a port with one signed-in, funded player: PETE_DEV_CASINO=:7788 go test ./internal/web -run TestDevCasino -timeout 0. Skipped without the env var. It wires its own routes because New() decides whether the casino exists at the moment it builds the mux, and the test rig signs the player in afterwards. Drive the table in a real browser before believing anything about it — this pass found a white-on-white verdict pill, a rack that collided with the dealer, and Hit still being offered over a table that was being paid out, none of which a Go test can see.

  • Deployed, 2026-07-14. https://games.parodia.dev is live. What that took, since the shape of it was not quite what this plan guessed:

    • The edge is Traefik, not Caddy (/mash/traefik/config/provider.yml, root-owned, file provider so it hot-reloads). The casino needed no router of its own — the existing pete router's rule grew a second host: Host(`news.parodia.dev`) || Host(`games.parodia.dev`), and ACME issued the cert on its own. DNS for the games host already pointed at the box.
    • Authentik lives at auth.parodia.dev, and the app's OAuth2 provider is "Pete News" (pk 12). It now holds both callbacks, strict: news and games. The provider's redirect_uris is a list of objects, not strings.
    • Server config gained [web.games] (enabled, host, matrix_server = "parodia.dev") and web.auth.cookie_domain = ".parodia.dev", which is what makes a news sign-in a games sign-in. Old host-only session cookies don't carry over — a signed-in user signs in once more, and after that the session spans both.
    • gogobee is not on that box. It runs on the LAN at reala@192.168.1.212, in a screen session, out of ~/gogobee, and it has no key for its own GitHub remote — deploy it with ssh -A so the pull rides your agent. Its escrow loop needs no new config: it is gated on the FEATURE_PETE_NEWS / PETE_INGEST_* env in ~/.env that adventure news already set. Restarted, it logs pete games: escrow loop started interval=3s.
  • Hangman, and it plays for chips. (2026-07-14. This revises §7's "Phase 2 — no escrow": the decision was that a free game in a casino reads as a demo, so hangman stakes chips like everything else and reuses the money path whole.)

    • The gallows is the payout meter. You pick a tier, stake, and get six lives. Every wrong guess draws a limb and takes a tenth off the base multiple — one event, shown as one event. Short phrases pay 2.6×, medium 2.0×, long 1.6× (short is hardest: fewer letters, less to go on). Floored at 1×, so a win never hands back less than the stake, and the rake still comes out of winnings only.
    • internal/games/hangman — the same pure reducer as blackjack, phrases embedded (phrases.txt, 205 of them, video-game flavoured, lifted from gogobee). State.Pays() is the number the felt quotes and the number settle() lands on: they were briefly two sums and the table advertised a pre-rake payout it didn't honour. One function now, and a test that walks a game asserting the quote equals the payout at every step.
    • The browser never sees the phrase. Cells carry the letter or an empty string — not the letter with a hidden flag — and the phrase itself is only added to the payload once the game is over and it decides nothing.
    • Two things the storage layer already gave us for free, and one it didn't: game_live_hands is keyed on the player, so "one game at a time" holds across games with no new code (a live hangman 409s a blackjack deal). But table() used to unmarshal any live row as a blackjack hand — which does not fail on a hangman row, it just silently yields an empty hand. It now dispatches on live.Game.
    • commit() in games_play.go is the shared settle path (seat → pay → audit → clear → touch). Both games go through it so neither re-derives an ordering that took a while to get right. casinoRoutes() is likewise the single route list, because devcasino_test.go has to wire its own mux and a second copy is a copy that stops including the newest game.
    • Driven in a real browser, win and loss: a 200 stake at 2.34× paid 455 and the bar landed on it; six wrong took the stake and nothing more; a reload mid-phrase brought back the board, the limbs, the multiple, the spent keys and the stake on the spot. Two layout bugs only the browser could show: the lives counter ran under the house rack, and the board wrapped a word early because the rack's clearance padding was on the whole column instead of the one row level with it.
  • Solitaire, and it plays for chips. (2026-07-14, jumping the queue ahead of trivia because the user asked for it.)

    • Vegas scoring, which is the only way solitaire has ever actually been a gambling game. You do not win or lose the deal — you buy the deck for your stake, and every card you get home to a foundation pays a fifty-second of the tier's multiple back. Cash the board whenever you like and keep what you've banked; a board that has gone dead is therefore a decision, not a wall. There is no undo, because the stake is spent the moment the deck is bought and an undo would be a way to walk a losing board backwards until it wins.
    • Three deals, and the two dials are the whole difficulty of Klondike: Patient (draw 1, unlimited passes, 1.4×, square at 38 cards), Vegas (draw 3, three passes, 2.2×, square at 24), Cutthroat (draw 3, one pass, 3.4×, square at 16). Tier.BreakEven() is what the felt quotes, because "2.2×" tells a player nothing about a game where the multiple is paid a card at a time.
    • internal/games/klondike — the same pure reducer. Pays() is one function for the same reason hangman's is. Two fuzzers hold the deck together: no card is ever lost or duplicated by any sequence of moves, and the board stays well-formed (every face-up run is a run, no column has cards face-down under nothing). The first thing a test caught was a recycle that reversed the waste — it flips as a block, so the card drawn first comes out first, and reversing would have dealt a different game on every pass and broken the seed.
    • The browser never sees the stock or a face-down card. Bigger than blackjack's hole card: that's most of the deck. Columns send a face-down count, never the cards. The events, unlike blackjack's, need no filtering — every card they carry is one the move just turned face up.
    • The table re-renders and animates the difference (FLIP). Blackjack plays back a script because a hand only grows at one end; solitaire moves runs from anywhere to anywhere and an auto-finish moves eleven cards at once. So solitaire.js measures where every card is, re-renders the board the server sent, and plays each card from its old place to its new one. The board on screen is therefore always exactly the board the server says exists. The events supply only what a diff can't: where a newly revealed card came from (the stock, or a flip in place) and what the board is now worth.
    • The rules are mirrored in JS, deliberately, and only to light up the columns a held card can go to. The server still decides every move; a disagreement snaps the board back to whatever it says. Being shown where a card goes is the game teaching you; being told no after you commit is the game scolding you.
    • Two things got extracted rather than copied, which is the rule this room runs on: casino-cards.js (the deck — faces, pips, the flip; was inside blackjack.js) and PeteFX.spot() (the pile of chips and the number under it, which owns the "the number is a readout of the pile" rule so no table can break it). Blackjack now uses both.
    • Driven in a browser, 2026-07-14, and it holds up. Every worry on the list came back clean. A Patient deck bought for 200 dealt a correct Klondike (28 cards across the seven columns, 24 left in the stock), quoted +5.4 a card and 38 more to break even — which is the tier's arithmetic, not a guess — and the money conserved end to end: 5,000 → 4,800 to buy the deck → one card home banked 5 → 4,805 cashed out. The FLIP does not jump on a re-render. The seven columns fit at 390px with no horizontal overflow (docScrollW == clientW), the rail stacks under the board rather than colliding with it, and the console is silent.
    • And blackjack survived the rewire, which was the real thing to check. Five hands, and the felt agreed with /api/games/table on every one. The rake still comes out of winnings only: a 400 win paid back 780 (the stake, plus 400 less 5%), and a push returned all 600 with nothing taken.
    • One thing to know before you go looking for a bug that isn't there: the bare <span data-chip> elements are the house rack's decoration. Only button[data-chip] carries a listener. A driver script that clicks [data-chip] hits the rack, nothing happens, and it looks like the bet is broken. Blackjack's action buttons are also [data-move="stand"], not [data-stand].
  • Trivia, and it plays for chips. (2026-07-14. Built, and now played — see "Driven in a browser" at the bottom of this entry, which is where the two bugs were.)

    • A ladder. Stake once, then answer a run of twelve. Every right answer multiplies what you're holding, a wrong one loses the lot, and you may walk with what you've built. Clearing all twelve ends the run and banks it — a ladder with no top is a slot machine you can't stop playing, and eventually every player loses everything to one bad question.
    • The clock is the game, and it is the anti-google mechanism. Trivia answers are lookupable, so a right answer is worth what it's worth when you give it: the multiple decays from Fast to Buzzer across the tier's limit (easy 1.30→1.10 over 20s, medium 1.55→1.20 over 18s, hard 1.90→1.30 over 15s), and running out of time loses exactly as much as being wrong. A timeout that merely cost you the speed bonus would make "look it up in the other tab" the strongest way to play. The countdown in the browser is decoration; the clock that scores is time.Now() against the AskedAt the server stamped. A reload does not restart it.
    • A pure reducer still, but the time is an argumentApplyMove(state, move, now). A reducer cannot own a timer, so it doesn't: the only thing that knows what o'clock it is remains the caller, and the engine stays value-in, value-out.
    • You cannot walk off the first rung (ErrNothingBanked). If you could, seeing question one and walking would be a free look: stake, peek, walk, restake, and reshuffle until the question is one you happen to know. The first question is the price of sitting down.
    • The browser never learns which answer is right. The four answers cross the wire without the index; that index is in the engine state, on the server. It comes back only in the event that decides the question, by which point knowing it is worth nothing. The ladder's remaining questions are never sent at all.
    • internal/games/trivia — engine, 11 tests. The one that matters most is the same one hangman needed: the number the felt quotes (Pays()) is asserted equal to the number settle() lands on, at every rung.
    • The bank is prefetched, not fetched per question (internal/opentdb, storage.DrawTrivia, table trivia_questions). A ladder asks a question every fifteen seconds with money on a clock the player is scored against; a live fetch would put OpenTDB's latency and rate limit inside that clock. The refill is a slow background drip (StartTriviaBank, 400 per difficulty, one request per six seconds, stops early when a batch adds nothing new), and a round never waits on it. Answers are shuffled per-game against the game's own seed, so where the right answer sits in the table tells a player nothing.
    • The dev rig seeds its own bank. A fresh dev database has an empty bank and every start 503s, so TestDevCasino now takes one real batch per difficulty from OpenTDB (seedTriviaBank) — fifty questions each, four ladders' worth, through the same fetch-decode-store path production uses. It does not run StartTriviaBank: a rig that spends its first two minutes dripping four hundred questions per difficulty out of a free API is a rig you cannot use.
    • Driven in a browser, 2026-07-14, and the clock and the money hold up. The ladder plays: a 200 stake on Easy dealt a real OpenTDB question with its entities decoded, the clock bar drained honestly (847px → 711px over three seconds, countdown 18.7s → 15.7s), two right answers compounded 1.00× → 1.26× → 1.58×, and walking paid exactly the 311 the felt had been quoting. The reveal marks the wrong pick red and the right answer green. A reload mid-rung brought the board back and — the thing that matters — the server's clock kept running through it (17.5s left before, 16.2s after; it does not restart). The timeout lands as a timeout, which was the loudest worry: going quiet through a 20s question fired the auto-submit at zero, came back 200 with a timeout event and "Out of time.", not a "that move isn't legal". The next question's answer is never sent (correct: -1 in the ask event); only the decided one reveals.
    • Two bugs, and only a browser could have found either.
      1. The spot printed double the stake after every settled game. standing() set spot.amount and then poured the chips on, and pour grows the pile from whatever it is told is already there — so a 200 stake settled to a spot reading 400, and a 400 one to 800. This is exactly the rule the felt is built on ("the number under the pile is a readout of the pile") failing quietly: the money was always right, the number under the chips was not. Blackjack and hangman pour without pre-setting; trivia now does too.
      2. The house rack sat on top of the multiplier at 390px. The rack is a 147px block inset 5.75rem from the edge, and that inset is not a margin — it is the width of blackjack's shoe, which the rack sits beside. On a phone that puts it in the middle of the felt, on top of trivia's "1.53×". On small screens the rack now shrinks and, where there is nothing in the corner, pulls into the corner. Which rack is which is what data-at says: unmarked is alone in the corner, shoe is blackjack (pull that one to the edge and it slides under the deck — this was caught after doing exactly that), rail is solitaire, whose rack isn't on the felt at all. All four tables re-checked at 390px and 1280px, live games on the felt: no overlap with text, no overlap with the shoe, no horizontal overflow, desktop geometry unchanged.
  • UNO, and it plays for chips. (2026-07-14. Built and tested, not yet driven in a browser — see "Next" below, because in this room that means it is not finished.)

    • You beat the table, or you don't. The user's call between three money models: stake once, go out first and take the tier's multiple; anybody else going out first takes the stake. The table size is the tier, which is the one dial UNO actually has: Duel (1 bot, 2.2×), Table (2 bots, 2.9×), Full House (3 bots, 3.6×). Rake on winnings only, as everywhere.
    • The multiples are measured, not guessed. A player who just plays the first legal card they hold goes out first 43% / 32% / 27% of the time against the bots, so the tiers are priced to make that lose about 8% a game — which leaves good play (holding the wilds back, dumping the colour you're long in) worth roughly the house's edge. The measurement is a throwaway test, not in the tree; re-run it if the bots or the tiers change, because the two are a pair.
    • The bots move inside ApplyMove, and that is what keeps solo UNO off a socket. One request plays your move and every bot turn it hands off to, and returns the lot as a script of events the felt plays back in order. §7 said solo first, no sockets; this is what that costs.
    • The RNG is in the state, not an argument. The bots choose and a spent deck reshuffles, so the engine needs randomness mid-game — but there is no rng alive across requests to hand it. So the seed rides in the state (which never leaves the server; the deck is in there too) and each step derives its own generator from the seed and the step count. Value in, value out, and a game still replays exactly as it fell.
    • The zero value of Color is Wild, deliberately. It was Red for an hour, and a wild played with the color field simply missing from the JSON went down as a red one. The zero has to be "no colour named", so the omission is refused instead of quietly meaning something. This is the kind of bug a rules test finds and a browser never would.
    • The browser never sees a bot's card. Not the deck, not a hand, not even the face of a card a bot drew — that last one is most of the deck, and sending it would turn counting cards into reading the network tab. Seats cross the wire as a name and a count. There are two walls: the engine only attaches a face to an event the seat may see it in, and viewUnoEvents drops it again anyway.
    • internal/games/uno — engine, 22 tests. The census one is the load-bearing one: 108 cards, each in exactly one place, asserted after every move of 100 games played out end to end. It is what would catch a reshuffle that leaks cards (the wilds go back into the deck as wilds, not as the colour they were played as) or a turn the bots never hand back.
    • PeteFX.flyNode — the throw, with the chip taken out of it. fly() is now that with a chip in it, because UNO wanted the same arc with a card in it. Extracted rather than copied, same as casino-cards.js and PeteFX.spot() before it.
    • The felt has no corner free for the house rack (bots along the top, piles in the middle, your hand at the bottom), so it takes solitaire's rail instead: data-at="rail", off the felt, no collision to check for.

Next, in order

  1. Drive UNO in a browser. It is built, the engine is tested and the handlers are tested, but nothing on this table has been looked at — and every game before it found bugs in that step that no Go test could see (a white-on-white pill, a rack on top of a multiplier, a spot printing double the stake). Specifically worth watching: the bots' turns play back as a readable script rather than a blur; the wild colour picker; a hand of ten cards at 390px with no sideways overflow; and a reload mid-game bringing the board back. PETE_DEV_CASINO=:7788 go test ./internal/web -run TestDevCasino -timeout 0, then Playwright.
  2. Phase 4 (hold'em) as below.
  3. Trivia is played but not deployed. Hangman, solitaire, trivia and now UNO are all still sitting on main un-deployed — the server runs StartTriviaBank, so its bank fills itself once the binary is out there, but the first player to try a ladder in the first minute after a deploy gets the 503.

Still open on the table itself, none of it blocking: split isn't implemented (the engine has no move for it), the felt is roomy at desktop widths with only one seat on it, and the chip animations are tuned for one player — a second seat would need the spot to be per-seat rather than the singleton it is now.

How the browser half fits together

  • GET /games (lobby), GET /games/blackjack (table) — signed-in only. On the games host, the mux prefixes /games onto the path, so the lobby is that host's /. Shared paths (/api/, /auth/, /static/) mean the same thing on every host and are left alone.
  • GET /api/games/table, POST /api/games/{buyin,cashout}, POST /api/games/blackjack/{deal,move} — session-authed, JSON, all returning the same tableView so the money and the felt can never disagree.
  • The browser never sees the shoe. The dealer's hole card is absent from the payload — not flagged hidden — until the reveal, and the deck lives only in game_live_hands. The response carries the engine's events (one per card off the shoe), which is what the table plays back as an animation.
  • Money order-of-operations: stake leaves the stack before the hand is dealt, in the same statement that checks it's there; the hand is seated (a plain INSERT on the primary key) before it can settle, which is what makes a double-clicked Deal a 409 with the stake refunded rather than a silently overwritten hand.

Notes for whoever picks this up

  • SQLite runs at MaxOpenConns(1) in both repos. Any db.Get().Exec inside an open transaction deadlocks against itself. Do the pre-work before Begin.
  • A buy-in can currently take a player into debt. DebitIdem inherits BLACKJACK_DEBT_LIMIT (default 1000), so someone with an empty wallet can buy €1,000 of chips, win, and cash out while still €1,000 down. That is exactly what gogobee's Matrix blackjack already allows, so it is consistent rather than a bug — but a web casino runs far more hands, and this is the knob to turn if the economy starts leaking. A buy-in-specific floor of 0 is a two-line change.
  • gogobee's blackjack taxes 5% of the gross payout into a community pot (communityTax). Pete's rake takes 5% of the profit. Deliberately different, and gentler; don't "fix" one to match the other without deciding which is right.

1. The three constraints everything else follows from

gogobee owns the euros. The ledger is euro_balances / euro_transactions (gogobee internal/db/db.go:1316,1324), tied to the wider economy — adventure, shop, lottery, mischief. Pete does not get a second wallet. Pete never writes a balance.

gogobee has no inbound API and isn't getting one. The only listening socket in the whole repo is the Matrix appservice transaction endpoint (internal/bot/appservice.go:255). Pete's own source says it plainly (internal/web/roster.go:23):

Direction of travel is gogobee → Pete ... Pete has no route back into the game box's network and we are not opening one.

So gogobee stays the only initiator. It polls Pete for work and pushes results back through the existing durable queue. Same as mischief (gogobee_mischief_plan.md:191-197).

One binary. Games live in the Pete process. gogobee already runs ~50 plugins and six games in one process with in-memory table state and it's fine. Caddy points games.parodia.dev at the same port; the mux branches on Host.


MAS imports the Authentik preferred_username as the Matrix localpart (gogobee_mischief_plan.md:176-186). So an Authentik session on Pete maps to a Matrix user deterministically:

OIDC preferred_username  ->  strings.ToLower(u)  ->  @<u>:parodia.dev

Pete's SessionUser (internal/web/auth.go) carries Sub/Name/Email today. Add PreferredUsername to the claims struct and the signed cookie payload. That is the whole identity story.

Note the existing precedent: email_nag.go:52 already asserts "Authentik usernames == Matrix localparts".


3. Money — session escrow, not per-hand settlement

Why not settle each hand

Mischief is fire-and-forget: place an order, gogobee claims it within 30s, nobody cares. A blackjack hand cannot work that way. If every bet round-trips through a poll loop you wait half a minute to be dealt, and again for the payout.

The model

Borrow the semantics hold'em already uses — euro.Debit(..., "holdem_buyin") (holdem.go:319), euro.Credit(..., "holdem_cashout") (holdem.go:371) — and apply it to the whole web casino:

  1. Buy in. You convert euros to chips for a games session. One debit. Tolerates poll latency.
  2. Play. Blackjack, UNO, hold'em, all at full speed against chips held in Pete's SQLite. Zero economy calls in the hot path.
  3. Cash out. Chips convert back to euros. One credit. Tolerates the same latency.

Two economy touches per session instead of two per hand. Poll latency stops mattering.

The invariant

A euro is either in gogobee's euro_balances or in Pete's chip escrow. Never both. It moves between them only via a GUID-idempotent claim.

Pete's balance display is advisory only, sourced from the roster push and up to 2 minutes stale. The authoritative check is euro.Debit at claim time. This preserves gogobee_mischief_plan.md:198-202"Pete never writes a balance, so no double-spend surface."

The prerequisite: euro idempotency (BLOCKING)

euro_transactions (db.go:1324) has no external id and no unique constraint. Debit is an atomic conditional UPDATE, but calling it twice debits twice. That is safe today only because every caller is a Matrix message that arrives once. A retrying poll loop breaks that: a claim that succeeds but whose ack is lost on the wire gets retried, and the player pays twice.

Before any of this ships:

ALTER TABLE euro_transactions ADD COLUMN external_id TEXT;
CREATE UNIQUE INDEX idx_euro_tx_external ON euro_transactions(external_id)
  WHERE external_id IS NOT NULL;

plus CreditIdem(userID, amount, reason, externalID) / DebitIdem(...) in euro.go that do the balance mutation and the transaction insert in one tx, and treat a unique-violation on external_id as success-already-applied. Everything web-initiated goes through these.


4. The wire protocol

All new Pete endpoints are bearer-authed with the existing ingest token (internal/web/adventure.go:307 bearerOK). gogobee grows its first GET path in internal/peteclient/client.go — the poll loop the mischief plan already calls for.

Pete serves (gogobee polls, ~3s interval)

GET  /api/games/escrow/pending        -> [{guid, matrix_user, kind: buyin|cashout, amount}]
POST /api/games/escrow/claim          <- {guid}          idempotent, marks claimed

gogobee pushes (existing peteclient queue, guid-idempotent)

POST /api/games/escrow/settled        -> {guid, ok: bool, reason?: "insufficient_funds", balance_after}

Reuse pete_emit_queue (client.go:121-125, INSERT OR IGNORE on guid PK) — it already does durability, backoff and parking. Don't build a second queue.

State machine (Pete side, table game_escrow)

requested -> claimed -> funded     (buyin ok; chips become spendable)
                     -> rejected   (insufficient funds; nothing spendable)
requested -> claimed -> settled    (cashout ok; chips destroyed, euros credited)

Poll interval 3s, not 30s: a player waiting to be dealt is watching a spinner. 3s of "buying chips…" is acceptable; 30s is not.

The reaper

Chips left in an abandoned session are euros in limbo. Auto-cash-out any session idle for 30 minutes. A crashed Pete must reconcile on boot: any claimed escrow with no settled push gets re-polled by GUID.


5. Code reuse — copy, don't share

Separate modules, both mine, and the shells diverge (Matrix vs HTTP). Copy the pure cores into pete/internal/games/, let them drift, no shared module.

Verdict per game

Game Copy Rewrite Notes
Hold'em ~2,700 LOC the shell The crown jewel. Take it all.
UNO ~1,400 LOC the turn engine Great primitives, unshippable engine.
Hangman ~250 LOC loading/persistence Clean rune-safe state machine.
Blackjack ~95 LOC everything else 95 lines is the entire core.
Trivia ~80 LOC everything else No question bank exists.

Hold'em — take almost all of it

Already mautrix-free, verified by import check:

  • holdem_cfr.go (1,285) — full CFR trainer + NPC policy runtime, info-set packing into a uint64, regret pruning, board-texture/SPR/equity bucketing. Plus the trained data/policy.gob (3.4 MB) and cmd/holdem-train, cmd/holdem-seed. This is the single highest-value asset in either repo.
  • holdem_equity.go + holdem_equity_range.go (548) — Monte-Carlo equity, equity-vs-range, draw/out detection. 100% pure, well tested.
  • holdem_betting.go (383) — side pots, min-raise, all-in, street completion. The fiddly poker rules you do not want to rewrite. Untested in gogobee — write tests as you port.
  • holdem_game.go, holdem_eval.go, holdem_render.go.

Entanglements to break (mechanical):

  1. id.UserIDPlayerID string (holdem_betting.go:283,314; holdem_eval.go winnings maps).
  2. Delete RoomID/DMRoomID from HoldemGame — table identity belongs to the shell.
  3. Hoist the four *time.Timer fields out of HoldemGame (holdem_game.go:92-95).
  4. LoadPolicy(path) does os.OpenLoadPolicyFrom(io.Reader), so the policy can be embed.FS'd.

Hand evaluation is not homegrown — holdem_eval.go:12 wraps poker.Evaluate. Just take the github.com/chehsunliu/poker dependency.

UNO — lift the primitives, rewrite the engine

Copy verbatim (already unit-tested in uno_test.go):

  • unoCard/unoColor/unoValue, canPlayOn, newUnoDeck, draw/reshuffle (uno.go:21-364)
  • The bot AI as free functions: botPickCard, botPickNormal, botPickAggressive, botPickColor (uno.go:1465-1585)
  • uno_nomercy.go is ~90% pure: scoring, stacking rules, no-mercy deck, second bot.

Rewrite the turn engine. In gogobee the engine is the message sender — executeMultiTurn, applyAndAnnounce, handlePlayerPlay mutate state and call p.SendReply(...) mid-turn, and their error returns mean "send failed", not "illegal move". There is no ApplyMove(game, move) (Result, error) seam anywhere. Disentangling that costs more than rewriting it against the (good) primitives. One near-seam worth keeping: applyCardEffects (uno_multi.go:1459) already returns a struct instead of sending.

Hangman — take the struct

hangmanGame + guessLetter/guessSolution/displayPhrase + the gallows [7]string ASCII art (hangman.go:26-274). Strip three fields (participants, solvedBy, threadID). Copy hangman_phrases.txt (237 lines) and embed it instead of os.Getenv("HANGMAN_PHRASE_FILE"). Drop the dreamclient translation path for v1.

Blackjack — retype it

handValue (correct soft-ace demotion), isBlackjack, and their tests. That's it — 95 lines. The rest is bjTable keyed by id.RoomID with timers embedded, and raw db.Exec SQL at blackjack.go:867.

Trivia — the question bank does not exist

trivia.go:288 fetches from OpenTDB live, one question per round:

apiURL := "https://opentdb.com/api.php?amount=1"

Reuse the category map (trivia.go:24-53) and calculateScore (time-decay, :536). For the web version, pre-fetch and cache a bank locally — a per-question HTTP call in a web game loop is a latency and rate-limit problem gogobee never had to care about at Matrix pace. Route outbound fetches through Pete's internal/safehttp (SSRF guard).

Trivia has no euro coupling today (points only). Keep it that way in v1 — it's the one game that can ship with zero escrow risk.

Two things that apply to every copied engine

Thread the RNG. Every card game uses the math/rand/v2 package global — blackjack.go:60, uno.go:186,277, holdem_game.go:102, and throughout the CFR/Monte-Carlo code. Nothing is seedable, which is why TestBotPickCard_* can only assert weak properties. The adventure half of gogobee already does this right (dnd_zone_combat.go:361 threads an explicit *rand.Rand via rand.NewPCG). The card games never adopted it. Threading rng *rand.Rand through the deck constructors is mandatory, not optional — ~20 call sites, and it's the difference between a testable engine and one you can only smoke-test. It also gives you a reproducible shuffle for dispute resolution.

Hoist the timers. bjTable.joinTimer/turnTimer/reminderTimers, unoGame.idleTimer/warningTimer, HoldemGame.actionTimer/warningTimer/idleTimer/idleWarningTimer — all live inside the game structs today. Timers are a shell concern. Game state must be a plain value you can serialize, which is also what makes restart-mid-hand survivable.

Build a cards package while you're at it

There is no shared cards package in gogobee — blackjack has its own deck (blackjack.go:32-75), UNO has its own (uno.go:130-189), hold'em uses the third-party lib. Consolidate into pete/internal/games/cards during the port rather than importing the duplication.


6. Architecture in Pete

internal/games/
  cards/        shared deck primitives (new; consolidates gogobee's duplicates)
  blackjack/    pure engine  — ApplyMove(state, move) (state, events, error)
  holdem/       pure engine  + cfr/ (copied) + policy.gob (embedded)
  uno/          pure engine  (rewritten) over copied primitives + bots
  hangman/      pure engine  (copied) + phrases.txt (embedded)
  trivia/       pure engine  (new) + cached question bank
  escrow/       chip ledger, the gogobee poll/push seam
  table/        session, seating, turn clocks, reconnect — the shell

Server-authoritative, always. The browser sends intents and never sees the deck. Any game with money attached cannot trust a client-reported result. This is why the engines have to be Go on Pete's side rather than ported to JS.

Every engine is a pure reducer: ApplyMove(state, move) (newState, []Event, error). Timers, sockets and persistence all live in table/. That's the seam gogobee never had, and it's what buys testability, replay, and surviving a redeploy.

Transport

  • Blackjack, Hangman, Trivia, UNO-solo — request/response over fetch. No sockets.
  • Hold'em, UNO-multi — WebSocket. Lobby, seating, presence, turn clocks, reconnect-mid-hand, spectators. This is the bulk of the total effort, and it is the only genuinely new infrastructure in the project.

Frontend

Pete has no SPA and no bundler today — server-rendered html/template + embed.FS, plain <script defer> tags, npm present only to run the Tailwind CLI. games.parodia.dev is the first real client-side app in the repo.

Precedent says this is survivable: weather-gl.js is 1,028 lines of hand-written WebGL2 with no build step. Do the same here — vanilla JS per game, no framework, no bundler, Tailwind for layout. Revisit only if it actually hurts.

Auth

Session cookie is host-only today — auth.go:151 sets Path but no Domain, so a news.parodia.dev session will not travel to games.parodia.dev. Set Domain: ".parodia.dev". Note this widens the cookie to every parodia.dev host including the landing site — a deliberate loosening, fine here, but not a freebie. Add the games.parodia.dev redirect URI to the pete app in Authentik.

Games require login. No anonymous play — there's money in it.


7. Ship order

Phase 0 — euro idempotency (gogobee). external_id column + unique index + CreditIdem/DebitIdem. Blocking; nothing else is safe without it.

Phase 1 — escrow + Blackjack. The full money loop against the simplest possible game (95 lines of logic). Buy in, play, cash out. This proves cross-subdomain auth, the identity mapping, the poll loop, the escrow state machine, the reaper, and the frontend shape — all against a game where the game cannot be what's broken.

Phase 2 — Trivia + Hangman. No escrow (trivia has no euro coupling; keep hangman's collaborative credit out of v1). Pure frontend and content work. Cheap wins, and they make the site feel like a place rather than a demo.

Phase 3 — UNO. Solo first (single-player vs bot, no sockets). Then multi, which is where the WebSocket infrastructure gets built. Forgiving latency, simple turn model — the right place to learn multiplayer.

Phase 4 — Hold'em. Last. It's the hardest engine (side pots, all-ins, split pots), the biggest port, and the one where collusion is a real threat rather than a theoretical one. Do it when the multiplayer plumbing has already survived contact with real players.


8. Risks

Economy inflation. A web casino runs orders of magnitude more hands per hour than Matrix-paced games ever did. Whatever the house edge is, it now compounds far faster in both directions. Before Phase 1 ships, decide: session buy-in caps, a daily net-win/loss ceiling, or a rake. This is the risk most likely to be discovered too late.

Restart mid-hand. Game state is in memory, so a Pete redeploy kills live tables — the same property gogobee has today, and it redeploys far less often than Pete does. Mitigate with serializable state (which the pure-reducer design gives for free) plus a drain-before-restart, not a second process.

Collusion in hold'em. Two browsers, one person, one table. Not solvable in v1; at minimum log seat/IP/session overlap so it's detectable after the fact.

The gogobee contract is cross-repo. roster_test.go already guards it: an unknown event_type is a 400 that makes gogobee's sender park the row. Add the same guard on the new escrow endpoints, and keep the payload structs in step across both repos by hand — that's the cost of copying instead of sharing, and it's the right trade here.


9. Open questions

  • Chips 1:1 with euros, or a separate denomination? 1:1 is simpler and honest. A separate denomination gives you a knob for the inflation problem.
  • Do web results feed the Matrix room? Pete already has a priority poster (adventure.go:151). "Reala just took a 12k pot" is a good bulletin, and this is nearly free.
  • NPC opponents at the web tables? The CFR bot is right there and it's good. It also means a table never sits empty, which matters a lot for a small community.