Repo: github.com/psnfiller/cheat
A gRPC implementation of the card game Cheat (aka BS): a dealer service,
two bot opponents (random and a heuristic "smart" bot with card-counting),
a personal web client, and a public multi-tenant web frontend at
cheat.psn.af. See prompt.md for the original game
rules spec.
proto/cheat.proto the gRPC service definition (see "Writing a client" below)
gen/cheatpb/ generated protobuf/gRPC code (go generate ./... / buf generate)
internal/game/ pure rules engine: deck, adjacency rules, turn/accusation state machine
internal/dealer/ gRPC service wrapping internal/game (single-writer goroutine per game)
internal/strategy/ bot decision logic (RandomBot, HeuristicBot), shared by real clients and the simulator
internal/simulate/ in-process game runner (no gRPC) for fast self-play tuning
internal/sharp/ the strongest brain: event-driven card counting + Bayesian accusations, with its own eval harness
internal/neural/ a neural policy trained by reinforcement learning (no programmed tactics; see cmd/neuraltrain)
internal/gamelog/ ground-truth game logging (see "Where the logs are" below)
internal/statusweb/ spectator HTML page for cmd/server
cmd/server/ the dealer + spectator page, for local play
cmd/randomclient/ bot: plays legal moves at random
cmd/smartclient/ bot: plays via internal/strategy.HeuristicBot
cmd/sharpclient/ bot: plays via internal/sharp.Brain (strongest; ~85% vs smartclient heads-up)
cmd/neuralclient/ bot: plays via internal/neural's RL-trained policy
cmd/neuraltrain/ trains internal/neural's model (evolution strategies over the real engine)
cmd/webclient/ human web client for local play (pairs with cmd/server)
cmd/cheatweb/ the public multi-tenant service behind cheat.psn.af
cmd/tune/ self-play parameter tuning for HeuristicBot
cmd/analyzelogs/ fits a "humanlike" bot profile from real logged games
go build -o /tmp/cheat-server ./cmd/server && /tmp/cheat-server -players 2
go build -o /tmp/cheat-smart ./cmd/smartclient && /tmp/cheat-smart
go build -o /tmp/cheat-webclient ./cmd/webclient && /tmp/cheat-webclient
Then open the URL cmd/webclient prints (default http://localhost:8081).
cmd/server's spectator page (default http://localhost:8080) shows the
live board without revealing anyone's hand.
Every game played through cmd/cheatweb (including the public site) is
logged for later analysis — one append-only JSON-Lines file per game.
- Production (cheat.psn.af):
/home/psn/cheat-container/logson chewy, mounted into the container at/data/logs(seedocker-compose.yml). - Local runs: pass
-log-dir <path>tocmd/cheatweb(defaults to/data/logs, which won't exist outside the container — pass an explicit path, or-log-dir ""to disable logging entirely; a failure to open the log directory is only ever a warning, never a reason to refuse to start a game).
Files are named <start-time>-<game-id>.jsonl, e.g.
20260704T173550Z-9cf7cd1a970cf301a25635a38a7118ff.jsonl. cmd/server /
cmd/randomclient / cmd/smartclient / cmd/webclient (the personal,
gRPC-based tools) don't produce these — only cmd/cheatweb's hosted
sessions do, since that's the only place with the concept of "a real human
playing."
What's in a log. Each line is one gamelog.TrainingEvent (see
internal/gamelog/gamelog.go), one of three types:
"play"— what was actually played, known and recorded the instant it's played, regardless of whether anyone ever challenges it (the dealer always knows the true cards; ground truth doesn't depend on a challenge happening). Fields:claimed_rank,count,actual_ranks(the real cards),was_cheating,had_truthful_option(did the player have a legal, honest move available and choose to lie anyway), andhand_size_before."decision"— an accuse-or-pass call by someone other than the current player, withdecision, the claim being judged (claimed_rank/count),pile_size,pt_hand_size, and (for"accuse") the resolvedwas_cheating."game_over"— justwinner_id.
Every event carries game_id, player_id, and is_human (so you can
filter to real human decisions vs. the bot's own). No PII is logged —
no IP address, no name, nothing beyond these game-domain facts.
Analyzing logs: go run ./cmd/analyzelogs -dir /home/psn/cheat-container/logs
prints sample sizes and a fitted "humanlike" HeuristicParams profile
(how often real players bluff when they don't have to, how their
accuse/pass calls correlate with claim size and pile pressure). Add
-json out.json to save it, then feed it into
cmd/tune -opponent humanlike -humanlike-params out.json to sanity-check
(or eventually tune) the bot against real human behavior instead of only
synthetic opponents. Treat the output as a rough starting point until
enough real games have accumulated — the tool prints a warning when the
sample is small.
A "client" is anything that speaks the Cheat gRPC service defined in
proto/cheat.proto and dials a running cmd/server (or any
internal/dealer.Server). The easiest way in is to read
cmd/randomclient/main.go top to bottom — it's the smallest complete
example — then cmd/smartclient/main.go if you want to see a real
decision-making brain wired in the same way.
Register(name)— blocks until the server-configured number of players have all calledRegister, then returns yourplayer_idand the full turn order. (The dealer deals the instant the last player registers.)StreamEvents(player_id)— open this right after registering. It's a server-streaming RPC that pushes everyGameEventyou need: public events everyone gets (CardsPlayed,AccusationWindowOpen,ChallengeResolved,TurnStarted,TurnPassed,GameOver,HandSizes) plus one private event only you get (YourHand, sent whenever your hand changes). Card faces are never sent to anyone except their owner.- React to events as they arrive:
TurnStarted{player_id == you}→ decide what to play, callPlay(player_id, card_indices, claimed_rank).card_indicesare positions into the hand you last saw viaYourHand— you don't have to actually hold the rank you claim; that's the whole game.claimed_rankmust be the same, one above, or one below the last claimed rank (wrapping King↔Ace), unless no claim has been made yet this round (the very first play of the game, or right after a challenge fully clears the pile — you'll know because that's exactly when the pile empties), in which case any rank is legal.AccusationWindowOpen{pt_player_id != you}→ decide whether to call it. CallAccuse(player_id)to say the last play was a lie, orPass(player_id)to let it stand. First response wins; a losing race just comes back withok=false, which is fine to ignore.GameOver→ done.
All four action RPCs (Play/Accuse/Pass, plus Register) return
{ok, error} rather than a gRPC error for game-rule rejections (wrong
turn, illegal claim, already resolved, etc.) — a gRPC-level error means
something more fundamental broke (bad player ID, game not started).
conn, _ := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
client := cheatpb.NewCheatClient(conn)
reg, _ := client.Register(ctx, &cheatpb.RegisterRequest{Name: "MyBot"})
stream, _ := client.StreamEvents(ctx, &cheatpb.StreamRequest{PlayerId: reg.PlayerId})
for {
ev, err := stream.Recv()
if err != nil { return }
switch e := ev.Event.(type) {
case *cheatpb.GameEvent_YourHand:
hand = e.YourHand.Cards
case *cheatpb.GameEvent_TurnStarted:
if e.TurnStarted.PlayerId == reg.PlayerId {
client.Play(ctx, &cheatpb.PlayRequest{PlayerId: reg.PlayerId, CardIndices: ..., ClaimedRank: ...})
}
case *cheatpb.GameEvent_AccusationWindowOpen:
if e.AccusationWindowOpen.PtPlayerId != reg.PlayerId {
client.Pass(ctx, &cheatpb.PassRequest{PlayerId: reg.PlayerId}) // or Accuse
}
case *cheatpb.GameEvent_GameOver:
return
}
}If you're writing a Go bot, you don't have to invent play/accusation logic
from scratch — internal/strategy.Bot is a small interface
(ChoosePlay(PlayContext, *rand.Rand) Decision,
ShouldAccuse(AccusationContext, *rand.Rand) bool) already implemented by
RandomBot and HeuristicBot. cmd/smartclient
shows the full wiring: track your own hand and the current claim window
from events, build a PlayContext/AccusationContext each time you need
a decision, and call into the bot. The same interface is what
internal/simulate drives directly (no gRPC) for fast self-play tuning —
see cmd/tune if you want to evaluate or tune a new strategy before
wiring it into a real client.
If your bot needs more than those thin per-decision contexts (card
counting, opponent modeling — anything derived from the whole event
stream), model it on internal/sharp instead (its design and tuning
history are written up in internal/sharp/README.md): its Brain consumes the
full GameEvent vocabulary via a small EventPlayer interface, and the
package's harness runs EventPlayers and strategy.Bots (via AdaptBot)
against each other in-process by the thousand, so a new brain can be
measured before it ever touches gRPC (go test ./internal/sharp; set
SHARP_SWEEP=1 for the slower diagnostics and parameter sweeps).
cmd/sharpclient is the reference gRPC wiring for an event-driven brain.
Only proto/cheat.proto is authoritative. Generate stubs for another
language with buf generate (or plain protoc) pointed at that file —
buf.yaml/buf.gen.yaml in this directory are the Go-specific config,
not needed for other languages, just the .proto itself.