A development journal documenting how a classic card game was built using AI-assisted development with Claude Code. Designed to be adapted into presentation slides.
"The {counselor} package is built...let's get started on this setback game"
Starting from an empty git repo, the first conversation with Claude Code established:
- Game variant: Classic 4-point Setback (High, Low, Jack, Game)
- Tech stack: R Shiny
- Players: 2v2 partnerships (human + AI partner vs 2 AI opponents)
- Theme: Family cookout personas with voice AI
- Documentation: This build log for presentation content
Claude Code entered plan mode to explore and design before writing any code. Key decisions made through interactive Q&A:
- Voice tech: Cartesia TTS + Deepgram STT (same stack as {counselor} package)
- AI personas: Uncle Earl (trash-talker), Auntie Dee (hype partner), Cousin Ray (quiet assassin)
- Build strategy: Click-based game first, voice layered on top
- Card rendering: Pure CSS + Unicode (no external image dependencies)
R/game_engine.R - The core card logic:
- Card representation using named vectors for rank ordering and game point values
- Deck creation, shuffling (Fisher-Yates via
sample()), and dealing (3 at a time, mimicking real dealing) - Trick resolution: trump beats all, highest of led suit wins otherwise
- Legal play validation: must follow suit if able
R/bidding.R - Auction-style bidding:
- Clockwise bidding starting left of dealer
- Must exceed prior bids (dealer can match)
- Forced bid of 2 when all others pass
61 game engine tests + 46 bidding tests = 107 tests passing
R/scoring.R - The four scoring points:
- High: Highest trump played (awarded to team that held it)
- Low: Lowest trump played (awarded to team that held it)
- Jack: Who captured the Jack of trump in a trick
- Game: Team with highest card-point total (A=4, K=3, Q=2, J=1, 10=10)
- Tie for Game = nobody gets the point
- Setback penalty: miss your bid, lose the bid amount from your score
- Win detection: first to 11, pitcher must be making bid to win
R/game_state.R - State machine driving the whole game:
- Phase transitions: pre_game -> dealing -> bidding -> pitching -> playing -> trick_done -> scoring -> hand_complete (or game_over)
process_bid(),process_play(),resolve_completed_trick(),score_hand()- each advances the state
The calculate_hand_points() function had a key naming collision - points[["team1"]] vs points[["team1_points"]]. Fixed by using a closure (add_point()) to consistently increment the right keys. Tests caught this immediately.
37 scoring tests passing. 144 total tests.
R/ai_strategy.R - Competent AI opponents:
- Hand evaluation: Scores each suit as potential trump based on high cards, length, and Jack presence
- Bidding: Evaluates hand strength, adjusts for partner's bid (more conservative when partner already bid)
- Card play: Separate logic for leading vs. following
- Leading: pitch strong trump first, then off-suit Aces, then trump length
- Following: play low if partner winning, try to beat opponents, dump low when hopeless
- Partnership awareness: Don't trump partner's winner, protect the Jack, lowest-winning-card strategy
R/ai_personas.R - Family cookout characters with dialogue banks:
| Character | Personality | Example Lines |
|---|---|---|
| Uncle Earl | Loud trash-talker | "Y'all not ready for this!", "These cards are RIGGED!" |
| Auntie Dee | Hype partner | "YES! That's my partner!", "That's just a setback for a comeback!" |
| Cousin Ray | Quiet assassin | "...four.", "nods", "I let Earl talk. I just play." |
Context-sensitive dialogue: different lines for bidding, winning tricks, losing tricks, capturing Jack, getting set, trash talk interjections.
AI forced dealer bid wasn't handled - the function tried to return "0" for a forced dealer instead of "2". Fixed by explicitly checking when pass isn't a valid option.
40 AI strategy tests passing (includes 20 randomized legality checks). 184 total tests.
www/cards.css - Pure CSS playing card rendering:
- Unicode suit symbols (no images needed)
- Hover lift animation for clickable cards
- Card back with diagonal stripe pattern
- Fanned hand layout with negative margins
- Responsive sizing for opponent cards
www/game.css - Game table layout:
- CSS Grid with north/south/east/west player areas and center trick area
- Dark theme (#0d1117 background) with green felt table (#1a6b3c)
- Team-colored scores and labels (green for your team, red for opponents)
- Speech bubbles with per-character colors (yellow for Earl, gray for Dee, silver for Ray)
- Bidding panel and game log styling
R/ui_helpers.R - HTML generation bridge:
render_card()- builds card HTML with suit colors, corners, center symbolrender_hand()- fan of cards with legal/disabled statesrender_trick_area()- positioned cards at N/S/E/Wrender_bid_controls()- dynamic buttons based on valid bidsrender_speech_bubble()- character-specific colored bubblesrender_game_log()- scrollable log with color-coded entries
www/game.js - Client-side interaction:
- Card click ->
Shiny.setInputValue('card_played', cardId) - Bid button click ->
Shiny.setInputValue('human_bid', bidValue) - AI delay: server sends
ai_delaymessage, JS waits 1s, then signalsai_turn_ready - Trick pause: 1.2s delay after trick completes before clearing
- Auto-scroll game log
app.R - Full Shiny app (250 lines):
reactiveValuesfor game state (no R6, no persistence needed)- Phase-based rendering: bid controls only show during bidding, cards only clickable on your turn
- AI turns triggered asynchronously via JS setTimeout (not polling)
- Speech bubbles update with persona dialogue on each AI action
- Game actions: "Deal" to start, "Next Hand" between hands, "New Game" after game over
Simulated a full hand entirely in R console (4 players, 6 tricks, scoring) - completed successfully with correct scoring.
Using invalidateLater() would create a polling loop that fires repeatedly. The JavaScript setTimeout approach fires exactly once per AI turn, giving precise control over timing. Each AI action follows: server triggers delay -> JS waits -> JS signals ready -> server executes AI turn.
After Phase 5 produced a playable game, real playtesting surfaced a set of issues that needed fixing - a critical click bug, layout improvements, smarter AI, and a scoring transparency feature that addresses one of the most confusing parts of Setback for new players.
One fun branding decision: all references to "trump" (the card game term) were renamed to "claude" throughout the codebase. Variables like trump_suit became claude_suit, get_legal_plays() checks for claude_suit, and the UI says "Claude is hearts!" instead of "Trump is hearts!" This was a global rename across all R files, CSS, JS, and tests - roughly 200+ occurrences.
The problem: After winning the bid, the player couldn't click any cards to pitch. The game appeared frozen.
Root cause: jQuery's .data() method caches values internally after first read. When Shiny replaces DOM elements during reactive updates (re-rendering the hand with clickable = TRUE), the cached jQuery data becomes stale or undefined. The click handler was silently failing because $(this).data('card-id') returned undefined on the fresh DOM elements.
The fix: Switch from .data('card-id') to .attr('data-card-id'), which always reads the raw HTML attribute from the current DOM:
// Before (broken after Shiny re-renders):
var cardId = $(this).data('card-id');
// After (always reads current DOM):
var cardId = $(this).attr('data-card-id');Same fix applied to bid buttons (.data('bid-value') -> .attr('data-bid-value')).
Lesson learned: When using Shiny with jQuery, always prefer .attr() over .data() for reading data-* attributes on elements that get replaced by reactive updates. jQuery's .data() caching is designed for static pages, not Shiny's dynamic DOM.
Moved the game log from below the table to a right-hand sidebar panel. The new layout uses CSS Flexbox:
- Left: Game table (flex: 1, takes remaining space)
- Right: 280px sidebar with scoring breakdown + game log
Added responsive breakpoints:
- Below 1100px: sidebar stacks below the table
- Below 700px: mobile-friendly single column
This freed up vertical space and keeps the scoring info visible alongside the game at all times.
In Setback, the Game point goes to whichever team captures the most card points in tricks (A=4, K=3, Q=2, J=1, 10=10). A single 10 is worth more than an Ace, King, and Queen combined. Smart players protect their 10s aggressively.
Added 10-card protection across all AI decision points:
| Situation | Old Behavior | New Behavior |
|---|---|---|
| Leading | Play highest off-suit card | Avoid leading 10s; play other high cards first |
| Can't win trick | Dump lowest card | Dump lowest non-10 card |
| Partner winning | Discard lowest | Discard lowest non-10 to protect Game points |
| Hand evaluation | Didn't account for 10s | Each 10 adds +0.2 to suit strength for bidding |
The AI now plays more like an experienced player - it won't casually throw away a 10 of diamonds on a losing trick when it could dump a 3 instead.
The motivation: "It would be really helpful to show how everyone counts their cards at the end - that's something I always feel confused about."
Added render_scoring_breakdown() to the sidebar, which appears after each hand and shows exactly how the four points were distributed:
Hand Scoring
-----------
HIGH A of hearts - Your Team
LOW 3 of hearts - Opponents
JACK Won by Cousin Ray - Opponents
GAME Opponents (27 vs 13)
Card values: A=4, K=3, Q=2, J=1, 10=10.
Team with higher total wins Game point.
Your Team: +1 Opponents: +3
Key design choices:
- Team-colored rows (green for your team, red for opponents, gray for ties/not in play)
- Jack shows who captured it, not just which team
- Game point shows both team totals so you can see the math
- Includes a card values reminder for new players
- Shows setback indicator in red when pitcher's team misses their bid
The problem: On the 2nd hand, if an AI player won the bid, the game froze with repeated "Not a legal play" warnings. Cards never appeared.
Root cause: In R, state$field <- NULL removes the key from the list rather than setting it to NULL. When start_new_hand() cleared fields like claude_suit and lead_suit, those keys disappeared from names(state). The sync loop for (n in names(state)) { gs[[n]] <- state[[n]] } then skipped them entirely, leaving stale values from the previous hand in the Shiny reactiveValues. The AI pitcher tried to play, but get_legal_plays() saw the old lead_suit and rejected the card.
The fix: Defined STATE_FIELDS <- names(new_game_state()) at app startup (which captures all field names including NULLs from the initial list() call). Replaced all 11 sync loops to use for (n in STATE_FIELDS) instead of for (n in names(state)). Now state[["lead_suit"]] returns NULL even when the key was removed, and it's explicitly written to gs.
Lesson learned: In R, list$field <- NULL deletes the key - it does NOT set it to NULL. This is a classic R gotcha that's especially dangerous with Shiny's reactiveValues, where stale state silently persists. Always use a fixed field list when syncing plain lists back to reactive state.
Moved all yellow status messages ("Press 'Deal' to start!", "[Player] is thinking...", "You won the bid!", "Your turn - play a card.", "[Team] wins the game!") from the center trick area to the upper-left corner of the green felt table using absolute positioning against the .game-table container.
All 184 tests continue to pass after all changes. The app.R parses correctly.
The {counselor} R package intercepts git commits and engages the developer in a voice conversation to review code changes before they proceed. It uses:
- {ellmer} for conversation intelligence with Claude
- Deepgram (Nova-2) for speech-to-text
- Cartesia (Sonic-2) for text-to-speech
The developer always has final say β nothing commits without explicit voice approval. Say "approve", "looks good", or "ship it" to proceed. Say "reject", "abort", or "wait" to block. Ask any question and the AI explains that part of the diff.
Claude Code writes code autonomously. That's powerful β but it means code can land in your repo that you haven't deeply reviewed. The traditional workflow is:
- AI writes code
- Developer glances at the diff
- Developer clicks "commit"
With counselor, the workflow becomes:
- AI writes code
- Developer runs
git commit - counselor intercepts β starts a voice conversation about the diff
- AI summarizes changes, flags concerns (security, risky operations, sensitive files)
- Developer asks questions, gets explanations
- Developer verbally approves or rejects
This is the difference between "human in the loop" (you're technically there) and "human on the loop" (you're actively engaged with the changes).
The pre-commit hook was installed via:
counselor::install_hooks(path = "path/to/setback")This writes an Rscript-based hook to .git/hooks/pre-commit that:
- Checks if counselor is installed
- Checks for
COUNSELOR_SKIPenv var (escape hatch) - Calls
counselor::review_commit()for voice review - Blocks the commit if the developer says "reject"
- Falls through gracefully if there's an error (no broken workflows)
API keys (Anthropic, Deepgram, Cartesia) must be in ~/.Renviron β not in a project-level .Renviron β because the git hook spawns its own Rscript process that only reads the global environment file:
# ~/.Renviron
ANTHROPIC_API_KEY='sk-ant-...'
DEEPGRAM_API_KEY='...'
CARTESIA_API_KEY='sk_car_...'
For the presentation, the workflow looks like:
- Claude Code makes a change to the setback game
- Developer stages and commits
- counselor's pre-commit hook fires β voice starts
- "I see you've modified the AI strategy to protect 10-value cards. The changes look reasonable β no security concerns. Would you like me to explain the logic?"
- Developer asks questions or says "approve"
- Commit proceeds (or gets blocked)
This demonstrates that voice is a natural interface for code review β your hands stay on the keyboard, you can't just click "approve" without engaging, and every session creates an audit trail.
(Coming next - Cartesia TTS + Deepgram STT for in-game AI personas)
(Coming next)
(Final thoughts on the AI-assisted development process)