Skip to content

Latest commit

Β 

History

History
350 lines (231 loc) Β· 15.8 KB

File metadata and controls

350 lines (231 loc) Β· 15.8 KB

Building Setback: A Card Game with Claude Code

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.


Phase 1: Foundation - Game Logic in Pure R

The Prompt

"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

Planning Phase

Claude Code entered plan mode to explore and design before writing any code. Key decisions made through interactive Q&A:

  1. Voice tech: Cartesia TTS + Deepgram STT (same stack as {counselor} package)
  2. AI personas: Uncle Earl (trash-talker), Auntie Dee (hype partner), Cousin Ray (quiet assassin)
  3. Build strategy: Click-based game first, voice layered on top
  4. Card rendering: Pure CSS + Unicode (no external image dependencies)

What Was Built

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

Testing

61 game engine tests + 46 bidding tests = 107 tests passing


Phase 2: Scoring + State Machine

What Was Built

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

Bug Caught

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.

Testing

37 scoring tests passing. 144 total tests.


Phase 3: AI Strategy + Personas

What Was Built

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.

Bug Caught

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.

Testing

40 AI strategy tests passing (includes 20 randomized legality checks). 184 total tests.


Phase 4: CSS Cards + Static UI

What Was Built

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 symbol
  • render_hand() - fan of cards with legal/disabled states
  • render_trick_area() - positioned cards at N/S/E/W
  • render_bid_controls() - dynamic buttons based on valid bids
  • render_speech_bubble() - character-specific colored bubbles
  • render_game_log() - scrollable log with color-coded entries

Phase 5: Interactive Game Loop

What Was Built

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_delay message, JS waits 1s, then signals ai_turn_ready
  • Trick pause: 1.2s delay after trick completes before clearing
  • Auto-scroll game log

app.R - Full Shiny app (250 lines):

  • reactiveValues for 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

Smoke Test

Simulated a full hand entirely in R console (4 players, 6 tricks, scoring) - completed successfully with correct scoring.

Architecture Decision: Why JS Callbacks?

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.


Phase 5.5: Bug Fixes, UI Polish + Strategy Improvements

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.

The "trump" -> "claude" Rename

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.

Bug Fix: Cards Unclickable After Bidding

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.

UI: Sidebar Layout

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.

AI Strategy: Protecting 10-Value Cards

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.

Scoring Breakdown: Making Card Counting Visible

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

Bug Fix: Stale State Across Hands (R NULL Gotcha)

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.

UI: Status Messages Repositioned

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.

Testing

All 184 tests continue to pass after all changes. The app.R parses correctly.


Human in the Loop: {counselor} Integration

What Is counselor?

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.

Why This Matters for Agentic Development

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:

  1. AI writes code
  2. Developer glances at the diff
  3. Developer clicks "commit"

With counselor, the workflow becomes:

  1. AI writes code
  2. Developer runs git commit
  3. counselor intercepts β€” starts a voice conversation about the diff
  4. AI summarizes changes, flags concerns (security, risky operations, sensitive files)
  5. Developer asks questions, gets explanations
  6. 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).

Setup in This Repo

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:

  1. Checks if counselor is installed
  2. Checks for COUNSELOR_SKIP env var (escape hatch)
  3. Calls counselor::review_commit() for voice review
  4. Blocks the commit if the developer says "reject"
  5. 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_...'

The Demo Flow

For the presentation, the workflow looks like:

  1. Claude Code makes a change to the setback game
  2. Developer stages and commits
  3. counselor's pre-commit hook fires β€” voice starts
  4. "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?"
  5. Developer asks questions or says "approve"
  6. 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.


Phase 6: Voice Integration

(Coming next - Cartesia TTS + Deepgram STT for in-game AI personas)


Phase 7: Polish

(Coming next)


Reflections

(Final thoughts on the AI-assisted development process)