Phase D backend: UNO is now a session like hold'em, not a single stake. You sit
with a buy-in stack, ante into a pot each hand, and leave with what's in front of
you. The engine lost its `You` constant and its measured multiples: ApplyMove
takes the acting seat, New takes a seat list, a Tier carries an ante instead of a
Base, and a hand settles by moving the pot to the winner (less rake, and never
when a bot takes it) rather than paying a multiple. A mercy kill puts a seat out
of the hand, not out of the game — the last one standing takes the pot.
The redaction moved to the web layer, where hold'em's already lives: the engine
now stamps every seat's hand onto its events, and viewUno/viewUnoEvents strip
everything that isn't the viewer's own. TestUnoViewNeverLeaksAnotherSeatsCards is
the wall. unoTable implements tableGame; /uno/{sit,move,leave,tables,stream,chat,
say} mirror hold'em, with stream/chat/say now shared game-agnostic handlers.
The frontend is not done: uno.js still calls the retired solo endpoint, so the
page renders but is not yet playable. All engine and web tests are green.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
Phase C's handler cutover: hold'em now runs on the shared-table runtime instead
of the solo game_live_hands blob. Solo is just a table nobody else has joined.
- holdem implements tableGame (name/timeout/stacks). timeout auto-checks-or-folds
a walked-away seat and marks it away; the audit is per-hand, with each pot's
rake on the winner's row alone so HouseTake cannot 4x itself.
- New endpoints: sit opens a table (or joins an open bot seat), leave gets you up
(LeaveTable + CloseTable behind the last human), plus tables (lobby), stream
(SSE), chat and say. The move path loads the player's table, applies at their
seat, commits under the version guard, and fans an SSE nudge.
- Engine grows Vacate/Occupy (a human leaving/joining between hands) and
TableSeats (a named human + bots). The view carries your_seat, since a shared
table has no seat-zero-is-you convention.
- Storage grows OpenSoloTable (stake+claim+create+seat in one tx), PlayerSeat,
and the abandoned-table reaper (AbandonedTables/ReapTable) — the seated-player
counterpart to the session reaper, since a walked-away stack is inside a blob
the session reaper cannot see. upsertSeat preserves last_seen so an auto-fold
never refreshes an away player's clock.
Not deployed, and the felt is not rewired yet: the frontend still assumes
seat zero is you, so this is browser-unverified. Solo sit/deal/play/leave and
two-human join/leave/reaper are covered by tests; the whole suite is green.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
Bust every hand and the dealer doesn't draw, which is right, but it was also
not turning over: reveal is only emitted by dealerPlay, and busting out skips
the dealer entirely. The browser kept the hole card face down while the settled
state printed the dealer's whole total under it. Emit the reveal on that path.
And standing your bet back up after a reload read the hand's bet straight off
the settled state, which a double has already doubled. Reload, double 200, and
the next hand starts with 400 on the spot.
Plus: the share card was hand-writing a Content-Length that ServeContent
overwrites anyway, and serving a zero-byte 200 for a room with no card.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
We never had Open Graph on the casino, and adding meta tags would not have
fixed it. Every route was behind requirePlayer, so a link pasted into a chat
window got a 302 to sign-in and unfurled as whatever the auth screen said:
"parodia.dev", no image, no description. Tags on a page a stranger cannot
fetch are tags nobody reads. So the casino now has a front door — a real page,
served to anybody, that says what the place is and offers a way in. You still
can't play from it, and every table still bounces you to sign-in.
The share card is drawn in Go rather than checked in as a picture, because the
casino has two names on a clock and the card keeps the joke: paste the link in
daylight and you get Casinopolis on green felt, paste it after six and the neon
is on and it says Casino Night Zone. Same roomAt() rule as everywhere else,
except the clock that decides is the server's — an unfurl bot has no evening of
its own. Both cards are drawn once, at first ask, and kept.
Two things worth keeping from building it. color.RGBA is alpha-premultiplied,
and the lamp over the table wrote raw channels next to a low alpha, which is
not a dim glow but an invalid colour: image/draw ran it past 255 and wrapped
the hue, and the first card came out with a blue dome over a green stripe. If
a colour here ever comes out impossible, look for a missing premultiply. And
og:image has to be an absolute URL that actually resolves, which is two
different addresses depending on how you arrived: /og.png on the games host
(hostRouter puts the /games back on) and /games/og.png anywhere else. The dev
rig advertised the first while serving only the second. The test now reads the
URL off the page and goes and fetches it, on both hosts, because an og:image
that 404s is worth exactly as much as no og:image.
Fredoka is vendored (OFL) — the page can reach for a font over the network and
a server drawing a PNG cannot.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
The engine has been able to play No Mercy since aca523e. Now a browser can.
The switch is a switch, not a fourth table: the tier is still the table size,
because that is what you are paid for, and the deck is the other dial. Six faces
the normal box does not print, sized by the card's own vars and never by the box
they sit in. The stack says what the bill is on the felt, in the turn line and on
the button, and under it the deck is dead — you cannot draw your way out of a
bill somebody has run up and pointed at you.
The wild draws glow. That started as decoration and turned out to be doing work:
No Mercy prints a coloured +4 right beside the wild one, and in a hand of twenty
the glow is what tells them apart.
A buried seat is not an empty one, which is the whole trap here — a seat killed
at twenty-five holds no cards, and neither does a seat that just went out and
won. The view asks the engine which it is instead of counting to zero, so the
winner is never the corpse.
Two bugs, both found in a browser and neither findable anywhere else:
The felt's stack bill was writing into the chip bar. It was [data-pending], and
so is the bar's "your chips are still coming" readout — and the bar lives inside
the table's own root and comes first in the document. A stack quietly overwrote
the escrow message and never appeared on the felt at all. A table's attributes
are not a private namespace.
And hold'em, re-driven on the 20M-hand policy (six hands, got up 61 ahead of a
100 buy-in, money conserved to the chip — Phase 4 closed), let you click a button
that did nothing: Deal, Leave and Top up stayed alive through the whole deal
animation, where send() drops the click on purpose. The lock is on the buttons
now, not only in the variable.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
Phase 4. Hold'em, and it's the only table in the casino that is a session
rather than a game: you buy in, play as many hands as you like, and leave with
what's in front of you. So the live row spans hands and chips cross the border
exactly twice. Everything in between is inside the engine.
The bots move inside ApplyMove, as UNO's do, which is what keeps poker off a
socket: shove all-in and the flop, turn, river, showdown and payout all come
back in one response, as a script the felt plays back.
The CFR policy the plan called "the single highest-value asset in either repo"
was never read. Not once, in the whole life of the game: the trainer wrote its
info-set keys under IP/OOP and the runtime looked them up under BTN/SB/BB, so
every lookup missed and fell silently through to a pot-odds heuristic. Nothing
looked broken, because a policy miss is not an error. And it was the wrong
policy anyway — ten big blinds deep, trained on a tree where a call always ends
the street, which is not poker. So the trainer is rewritten to play the real
engine through the real reducer, at every stack depth the table deals, and the
trainer and the table now build the key with the same function so they cannot
drift apart again. A test fails if the bots stop finding themselves in it.
Three money bugs, and the tests earned their keep. Chip conservation across a
hundred sessions caught an uncalled bet that minted chips. A var-init ordering
trap meant every card was identical, every showdown tied and every bot believed
it held exactly 50% equity. And the browser caught the rake being silently
zero — the tier said 5 meaning percent, the casino handed it 0.05 meaning a
fraction, and integer division took the house's cut down to nothing.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
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
Solitaire, Vegas rules — the only shape solitaire has ever had as a
gambling game. You don't win or lose the deal: the stake buys the deck
outright, 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, so a board that has gone dead is a decision rather
than a wall. No undo: 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 draws one with unlimited passes and pays 1.4x, so it takes 38
cards home to get square. Vegas draws three, three times round, 2.2x,
square at 24. Cutthroat draws three and gives you one pass, 3.4x, square
at 16 — most of those boards never clear, and you're ahead long before
they would.
internal/games/klondike is the same pure reducer as the other two, and
Pays() is one function for the same reason hangman's is. Two fuzzers hold
the deck together: no sequence of moves can lose or duplicate a card, and
the board stays well-formed. They earned their keep immediately — the
first thing they caught was a recycle that reversed the waste. It flips as
a block, so the card drawn first comes out first, and reversing it would
have dealt a different game on every pass and quietly broken the seed in
the audit log.
The browser never sees the stock or a face-down card, which here is most
of the deck rather than blackjack's one hole card: a column sends how many
cards are under it, never which.
The table re-renders and animates the difference. Blackjack plays back a
script because a hand only ever grows at one end; solitaire moves runs
from anywhere to anywhere and an auto-finish moves eleven cards at once,
so a script of "append this card there" would be a second engine over here
and it would be the one that's wrong. Instead the board on screen is
always exactly the board the server says exists, and each card is played
from where it just was to where it now is. The events supply only what a
diff can't: where a newly-revealed card came from, and what the board is
worth.
The rules are mirrored in JS on purpose, and only to light up the columns
a held card can go to. Being shown where a card goes is the game teaching
you; being told no after you commit is the game scolding you. The server
still decides, and a disagreement snaps the board back to what it says.
Two things came out into the open rather than being copied, which is the
rule this room runs on: casino-cards.js (the deck — faces, pips, the flip)
and PeteFX.spot() (the pile of chips and the number under it, which now
owns the rule that the number is a readout of the pile). Blackjack uses
both.
Not yet driven in a browser.
Hangman, and it plays for chips — which the plan had down as a free game, on
the grounds that trivia has no euro coupling in gogobee. But a free game in a
casino reads as a demo, so it stakes like everything else.
The idea that makes it a casino game rather than hangman with a wager stapled
on: the gallows is the payout meter. A wrong guess draws a limb *and* takes a
tenth off what a win is worth, because those are the same event and showing
them as one is the entire reason to bet on this. Short phrases pay 2.6x (fewer
letters, less to go on), long ones 1.6x — the floor is 1x, so a win never hands
back less than the stake, and the rake still comes out of winnings only.
State.Pays() is the number the felt quotes and the number settle() lands on.
They were briefly two sums, and the table spent an afternoon advertising a
pre-rake payout it didn't honour.
Two things the storage layer had already decided for us, and one it hadn't:
game_live_hands is keyed on the player, so "one game at a time" holds across
games for free (a live hangman 409s a blackjack deal, stake intact). But
table() unmarshalled every live row as a blackjack hand, which does not fail on
a hangman row — it quietly yields an empty hand. It dispatches on the game now.
commit() is the settle path both games share, and casinoRoutes() the one route
list, since the dev rig wires its own mux and a second copy is a copy that stops
including the newest game.
Driven in a browser, win and loss: 200 at 2.34x paid 455 and the bar landed on
it; six wrong took the stake and no more; a reload mid-phrase brought back the
board, the limbs, the multiple, the spent keys and the chips on the spot. The
browser found the two bugs a Go test can't — a lives counter under the house
rack, and a word wrapping early because the rack's clearance was on the whole
column instead of the one row beside it.
The tables were living in the news app's shell: Pete's face in the header
and the footer, the channel nav, search, the reader, the weather canvas,
the PWA. A casino is not a news page with a felt on it.
So it gets its own layout. What carries over is the design language — the
four palette vars, Fredoka/Nunito, the fat rounded cards, the dropped
shadow. What doesn't is every control it has no use for. gamesPage stops
embedding the news pageData, which is what keeps the furniture from
drifting back one convenient field at a time.
It keeps a clock, but tells a different joke with it: Casinopolis by day,
Casino Night Zone from six, palette and felt and the sign over the door all
changing together. The rule lives in roomAt() for the first paint and again
in the browser, so a player abroad gets their own evening.
And the cards are cards now — corner indices in both corners, the bottom
one upside down as printed, pips on the three-by-seven grid every real deck
has used for four hundred years, courts as a letter with the suit over each
shoulder. Driven in a real browser, both rooms, dealt through to a payout.
The engine, the escrow and the wire were all in place; nothing had a browser on
the end of it. This is that end: a lobby, a table, and the five endpoints between
them.
The browser holds no game. It sends intents and gets back a view — the cards it
is entitled to see, and the script of how they arrived, one event per card off
the shoe. The dealer's hole card is not in the payload at all until the reveal,
because a field the client is told to ignore is a field somebody reads in
devtools. The shoe lives in game_live_hands, which also means a redeploy
mid-hand no longer costs a player their stake: the hand is still there when they
come back.
The money is ordered so nothing can be spent twice. The stake leaves the stack in
the same statement that checks it exists, before a card is dealt. Every new hand
is seated with a plain INSERT, so a double-clicked Deal is decided by the primary
key rather than by a read that raced — it loses, gets its chips back, and the
hand in progress is untouched. A double takes its raise up front and hands it
straight back if the engine refuses the move.
Cards are dealt rather than swapped in — they fly out of the shoe and turn over,
which was a requirement and not a flourish. The faces and the chips are still
plain; that's next.