Three bearer-authed endpoints and gogobee can work the border: poll what's waiting, claim a row, report what happened to the money. The storage layer underneath was already done; this is the transport, and deliberately nothing more. All three are idempotent, because the thing on the other end of them is a retrying queue and the thing they move is money. A verdict delivered three times creates chips once. A rejected buy-in moves nothing and clears the pending amount so it stops eating the table cap. A cash-out gogobee couldn't pay gives the chips back rather than vanishing them from both sides. A verdict for a row Pete has never heard of is a 400, not a shrug: gogobee has by then moved real euros against it, and no amount of retrying invents the missing row. Under the contract the adventure seam set, a 400 parks it in gogobee's queue where a human can find it.
21 KiB
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-13
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 must look like Pete. Same cute, bubbly interface:
layout.html, Fredoka/Nunito, the CSS vars,rounded-3xlcards,shadow-pete. Not a separate-looking SPA bolted onto the same box. - 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, andCreditIdem/DebitIdemininternal/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. (gogobeeab2bcf0) -
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. (pete8310b30) -
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. (pete8310b30) -
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. (petef9a98f7) -
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, callsDebitIdem/CreditIdemagainst the escrow GUID, and pushes the verdict back throughpete_emit_queue— which grew apathcolumn so escrow verdicts ride the same durable queue as adventure facts rather than getting a second one.peteclient.Flushsends 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.
Next, in order
- Identity.
PreferredUsernameinto the OIDC claims struct and the signed cookie; cookieDomain: ".parodia.dev"so a news session travels to games. Add the games redirect URI to thepeteapp in Authentik. - Frontend. Host branching in the mux, lobby + blackjack table, animated dealing.
Nothing yet opens an escrow row from a browser —
RequestBuyIn/RequestCashOuthave no HTTP surface, on purpose: they need a signed-in Matrix identity, which is step 1. - Then Phase 2 (trivia, hangman), 3 (UNO), 4 (hold'em) as below.
Notes for whoever picks this up
- SQLite runs at
MaxOpenConns(1)in both repos. Anydb.Get().Execinside an open transaction deadlocks against itself. Do the pre-work beforeBegin. - A buy-in can currently take a player into debt.
DebitIdeminheritsBLACKJACK_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.
2. Identity — free, no link codes
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:
- Buy in. You convert euros to chips for a games session. One debit. Tolerates poll latency.
- Play. Blackjack, UNO, hold'em, all at full speed against chips held in Pete's SQLite. Zero economy calls in the hot path.
- 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_balancesor 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 auint64, regret pruning, board-texture/SPR/equity bucketing. Plus the traineddata/policy.gob(3.4 MB) andcmd/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):
id.UserID→PlayerID string(holdem_betting.go:283,314;holdem_eval.gowinnings maps).- Delete
RoomID/DMRoomIDfromHoldemGame— table identity belongs to the shell. - Hoist the four
*time.Timerfields out ofHoldemGame(holdem_game.go:92-95). LoadPolicy(path)doesos.Open→LoadPolicyFrom(io.Reader), so the policy can beembed.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.gois ~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.