# Tutorial: Implementing Gin Rummy ## Overview In this tutorial, you'll build a complete Gin Rummy card game using the CardsStarterKit framework. This is a more complex implementation than the magic trick, teaching you: - Managing larger hands (10 cards vs 2) - Implementing meld detection (sets and runs) - Creating intermediate NPC opponents - Turn-based gameplay with draw/discard mechanics - Calculating deadwood and scoring - Multi-player support - Advanced UI for card organization **Target Audience:** MonoGame developers new to card games **Estimated Time:** 6-8 hours **Difficulty:** Intermediate --- ## What is Gin Rummy? ### Game Rules Summary **Objective:** Form melds (sets/runs) and minimize deadwood (unmatched cards). **Setup:** - 2-4 players (we'll support up to 4) - Each player receives 10 cards - Remaining cards form the stock pile - Top card of stock is flipped to start discard pile **Gameplay Flow:** 1. **Draw Phase:** Player draws from stock or discard pile 2. **Discard Phase:** Player discards one card to discard pile 3. **Optional Knock:** If deadwood ≤ 10 points, player can knock 4. **Gin:** If deadwood = 0, player declares "Gin!" **Melds:** - **Set:** 3-4 cards of same rank (e.g., 7♥ 7♠ 7♣) - **Run:** 3+ consecutive cards of same suit (e.g., 4♠ 5♠ 6♠) **Deadwood Points:** - Ace = 1 point - 2-10 = face value - J, Q, K = 10 points **Scoring (Single Round - Tutorial Focus):** - **Gin:** Knocker gets opponent's deadwood + 25 bonus - **Knock:** Knocker gets difference in deadwood - **Undercut:** If opponent has less/equal deadwood, opponent gets difference + 25 bonus ### What We'll Build This tutorial focuses on **single-round gameplay** to keep it manageable. At the end, we'll discuss extending it to full match scoring (first to 100 points). --- ## Part 1: Project Structure ### Step 1.1: Create Directory Structure ```bash mkdir -p 3-Games/GinRummy/Core mkdir -p 3-Games/GinRummy/Players mkdir -p 3-Games/GinRummy/Rules mkdir -p 3-Games/GinRummy/UI mkdir -p 3-Games/GinRummy/AI ``` Your structure will look like: ``` 3-Games/GinRummy/ ├── Core/ │ ├── GinRummyCardGame.cs │ ├── GinRummyGameState.cs │ ├── Meld.cs │ └── MeldDetector.cs ├── Players/ │ ├── GinRummyPlayer.cs │ └── GinRummyNPCPlayer.cs ├── Rules/ │ ├── KnockRule.cs │ ├── GinRule.cs │ └── TurnCompleteRule.cs ├── UI/ │ └── HandOrganizer.cs └── AI/ └── GinRummyAI.cs ``` --- ## Part 2: Core Data Structures ### Step 2.1: Define Game State **Create:** `Core/Game/GinRummy/Game/GinRummyGameState.cs` ```csharp namespace GinRummy { /// /// States of a Gin Rummy game /// public enum GinRummyGameState { /// /// Dealing initial hands /// Dealing, /// /// Player's turn - drawing phase /// Drawing, /// /// Player's turn - discarding phase /// Discarding, /// /// Player knocked - showing hands /// Knocked, /// /// Player got Gin - showing hands /// Gin, /// /// Calculating scores /// Scoring, /// /// Round complete - showing results /// RoundEnd, /// /// Waiting between turns /// Waiting } } ``` ### Step 2.2: Define Meld Structure **Create:** `Core/Game/GinRummy/Game/Meld.cs` ```csharp using System.Collections.Generic; using System.Linq; using CardsFramework.Cards; namespace GinRummy { /// /// Types of melds in Gin Rummy /// public enum MeldType { /// /// 3+ cards of same rank (e.g., 7♥ 7♠ 7♣) /// Set, /// /// 3+ consecutive cards of same suit (e.g., 4♠ 5♠ 6♠) /// Run } /// /// Represents a meld (set or run) of cards /// public class Meld { /// /// Type of this meld /// public MeldType Type { get; set; } /// /// Cards in this meld /// public List Cards { get; set; } /// /// Total point value of cards in this meld /// public int PointValue { get { return Cards.Sum(card => GetCardPoints(card)); } } public Meld() { Cards = new List(); } /// /// Creates a meld from a list of cards /// public Meld(MeldType type, List cards) { Type = type; Cards = new List(cards); } /// /// Gets the point value of a card for deadwood calculation /// public static int GetCardPoints(TraditionalCard card) { switch (card.Value) { case CardValue.Ace: return 1; case CardValue.Two: return 2; case CardValue.Three: return 3; case CardValue.Four: return 4; case CardValue.Five: return 5; case CardValue.Six: return 6; case CardValue.Seven: return 7; case CardValue.Eight: return 8; case CardValue.Nine: return 9; case CardValue.Ten: case CardValue.Jack: case CardValue.Queen: case CardValue.King: return 10; default: return 0; } } /// /// Checks if this meld is valid /// public bool IsValid() { if (Cards.Count < 3) return false; if (Type == MeldType.Set) return IsValidSet(); else return IsValidRun(); } /// /// Validates a set (same rank) /// private bool IsValidSet() { if (Cards.Count < 3 || Cards.Count > 4) return false; CardValue firstValue = Cards[0].Value; // All cards must have the same value return Cards.All(card => card.Value == firstValue); } /// /// Validates a run (consecutive same suit) /// private bool IsValidRun() { if (Cards.Count < 3) return false; CardSuit suit = Cards[0].Type; // All cards must be same suit if (!Cards.All(card => card.Type == suit)) return false; // Sort cards by value var sortedCards = Cards.OrderBy(card => GetCardNumericValue(card)).ToList(); // Check consecutive values for (int i = 1; i < sortedCards.Count; i++) { int prevValue = GetCardNumericValue(sortedCards[i - 1]); int currValue = GetCardNumericValue(sortedCards[i]); if (currValue != prevValue + 1) return false; } return true; } /// /// Gets numeric value for sorting (Ace=1, King=13) /// private static int GetCardNumericValue(TraditionalCard card) { switch (card.Value) { case CardValue.Ace: return 1; case CardValue.Two: return 2; case CardValue.Three: return 3; case CardValue.Four: return 4; case CardValue.Five: return 5; case CardValue.Six: return 6; case CardValue.Seven: return 7; case CardValue.Eight: return 8; case CardValue.Nine: return 9; case CardValue.Ten: return 10; case CardValue.Jack: return 11; case CardValue.Queen: return 12; case CardValue.King: return 13; default: return 0; } } public override string ToString() { string cardList = string.Join(", ", Cards.Select(c => $"{c.Value} of {c.Type}")); return $"{Type}: [{cardList}]"; } } } ``` **Key Features:** - `IsValid()`: Validates sets (same rank) and runs (consecutive, same suit) - `PointValue`: Sums card values for scoring - `GetCardPoints()`: Standard Gin Rummy point values ### Step 2.3: Create Meld Detector This is the brain of Gin Rummy - finding optimal melds to minimize deadwood. **Create:** `Core/Game/GinRummy/Game/MeldDetector.cs` ```csharp using System.Collections.Generic; using System.Linq; using CardsFramework.Cards; namespace GinRummy { /// /// Detects valid melds in a hand and calculates deadwood /// public class MeldDetector { /// /// Finds the optimal set of melds that minimizes deadwood /// public static List FindOptimalMelds(List cards) { // Find all possible melds List allPossibleMelds = FindAllPossibleMelds(cards); // Find combination with minimum deadwood return FindBestMeldCombination(cards, allPossibleMelds); } /// /// Finds all possible valid melds in the hand /// private static List FindAllPossibleMelds(List cards) { List melds = new List(); // Find all sets (3-4 of same rank) melds.AddRange(FindSets(cards)); // Find all runs (3+ consecutive same suit) melds.AddRange(FindRuns(cards)); return melds; } /// /// Finds all possible sets in the hand /// private static List FindSets(List cards) { List sets = new List(); // Group by card value var grouped = cards.GroupBy(card => card.Value) .Where(g => g.Count() >= 3); foreach (var group in grouped) { var cardList = group.ToList(); // Sets of 3 if (cardList.Count >= 3) { sets.Add(new Meld(MeldType.Set, cardList.Take(3).ToList())); } // Sets of 4 if (cardList.Count == 4) { sets.Add(new Meld(MeldType.Set, cardList)); } } return sets; } /// /// Finds all possible runs in the hand /// private static List FindRuns(List cards) { List runs = new List(); // Group by suit var bySuit = cards.GroupBy(card => card.Type); foreach (var suitGroup in bySuit) { var sortedCards = suitGroup.OrderBy(card => GetCardNumericValue(card)).ToList(); // Find consecutive sequences for (int i = 0; i < sortedCards.Count; i++) { List sequence = new List { sortedCards[i] }; // Build consecutive sequence for (int j = i + 1; j < sortedCards.Count; j++) { int prevValue = GetCardNumericValue(sortedCards[j - 1]); int currValue = GetCardNumericValue(sortedCards[j]); if (currValue == prevValue + 1) { sequence.Add(sortedCards[j]); } else { break; } } // Add all possible runs of length 3+ if (sequence.Count >= 3) { // Add runs of different lengths for (int length = 3; length <= sequence.Count; length++) { runs.Add(new Meld(MeldType.Run, sequence.Take(length).ToList())); } } } } return runs; } /// /// Finds the best combination of non-overlapping melds /// private static List FindBestMeldCombination( List allCards, List possibleMelds) { List bestCombination = new List(); int minDeadwood = CalculateDeadwood(allCards, new List()); // Try all combinations of melds FindBestCombinationRecursive( allCards, possibleMelds, new List(), ref bestCombination, ref minDeadwood); return bestCombination; } /// /// Recursive helper to find best non-overlapping meld combination /// private static void FindBestCombinationRecursive( List allCards, List possibleMelds, List currentCombination, ref List bestCombination, ref int minDeadwood) { // Calculate deadwood for current combination int deadwood = CalculateDeadwood(allCards, currentCombination); // Update best if this is better if (deadwood < minDeadwood) { minDeadwood = deadwood; bestCombination = new List(currentCombination); } // Try adding each remaining meld for (int i = 0; i < possibleMelds.Count; i++) { Meld meld = possibleMelds[i]; // Check if this meld overlaps with current combination if (!OverlapsWithCombination(meld, currentCombination)) { // Add this meld and recurse currentCombination.Add(meld); FindBestCombinationRecursive( allCards, possibleMelds.Skip(i + 1).ToList(), currentCombination, ref bestCombination, ref minDeadwood); // Backtrack currentCombination.RemoveAt(currentCombination.Count - 1); } } } /// /// Checks if a meld uses any cards already in the combination /// private static bool OverlapsWithCombination(Meld meld, List combination) { var usedCards = new HashSet(); foreach (var existingMeld in combination) { foreach (var card in existingMeld.Cards) { usedCards.Add(card); } } return meld.Cards.Any(card => usedCards.Contains(card)); } /// /// Calculates deadwood (unmatched cards) point value /// public static int CalculateDeadwood(List allCards, List melds) { // Get all cards in melds var meldedCards = new HashSet(); foreach (var meld in melds) { foreach (var card in meld.Cards) { meldedCards.Add(card); } } // Calculate deadwood points int deadwood = 0; foreach (var card in allCards) { if (!meldedCards.Contains(card)) { deadwood += Meld.GetCardPoints(card); } } return deadwood; } /// /// Gets numeric value for card ordering /// private static int GetCardNumericValue(TraditionalCard card) { return Meld.GetCardNumericValue(card); } } } ``` **Algorithm Explanation:** 1. **Find All Possible Melds:** Identify every valid set and run 2. **Try Combinations:** Recursively try non-overlapping meld combinations 3. **Minimize Deadwood:** Keep the combination with lowest deadwood points 4. **Backtracking:** Classic combinatorial optimization problem This is computationally intensive for 10 cards but acceptable for gameplay. --- ## Part 3: Player Classes ### Step 3.1: GinRummyPlayer **Create:** `Core/Game/GinRummy/Players/GinRummyPlayer.cs` ```csharp using System.Collections.Generic; using CardsFramework.Cards; using CardsFramework.Players; using CardsFramework.Game; namespace GinRummy { /// /// Represents a player in Gin Rummy /// public class GinRummyPlayer : Player { #region Properties /// /// Current melds in player's hand /// public List Melds { get; set; } /// /// Deadwood point value /// public int Deadwood { get; set; } /// /// Whether player has knocked /// public bool HasKnocked { get; set; } /// /// Whether player has gin /// public bool HasGin { get; set; } /// /// Score for this round /// public int RoundScore { get; set; } /// /// Total score across all rounds (for future multi-round support) /// public int TotalScore { get; set; } /// /// Whether it's currently this player's turn /// public bool IsMyTurn { get; set; } /// /// Whether player has drawn this turn /// public bool HasDrawn { get; set; } #endregion #region Initialization public GinRummyPlayer(string name, CardsGame game) : base(name, game) { Melds = new List(); Deadwood = 0; HasKnocked = false; HasGin = false; RoundScore = 0; TotalScore = 0; IsMyTurn = false; HasDrawn = false; } #endregion #region Methods /// /// Analyzes hand to find optimal melds and calculate deadwood /// public void AnalyzeHand() { List handCards = new List(); for (int i = 0; i < Hand.Count; i++) { handCards.Add(Hand[i]); } // Find optimal melds Melds = MeldDetector.FindOptimalMelds(handCards); // Calculate deadwood Deadwood = MeldDetector.CalculateDeadwood(handCards, Melds); // Check for Gin (no deadwood) HasGin = (Deadwood == 0 && handCards.Count == 10); } /// /// Checks if player can knock (deadwood <= 10) /// public bool CanKnock() { return Deadwood <= 10 && Hand.Count == 10; } /// /// Resets player state for new round /// public void ResetForNewRound() { Melds.Clear(); Deadwood = 0; HasKnocked = false; HasGin = false; RoundScore = 0; IsMyTurn = false; HasDrawn = false; } #endregion } } ``` **Key Methods:** - `AnalyzeHand()`: Uses `MeldDetector` to find melds and deadwood - `CanKnock()`: Checks if knock is legal - `ResetForNewRound()`: Prepares for next round ### Step 3.2: GinRummyNPCPlayer **Create:** `Core/Game/GinRummy/Players/GinRummyNPCPlayer.cs` ```csharp using CardsFramework.Game; namespace GinRummy { /// /// NPC-controlled Gin Rummy player /// public class GinRummyNPCPlayer : GinRummyPlayer { /// /// NPC decision-making component /// public GinRummyNPC NPC { get; private set; } public GinRummyNPCPlayer(string name, CardsGame game) : base(name, game) { NPC = new GinRummyNPC(this); } } } ``` Simple wrapper - the NPC logic will be in a separate class. --- ## Part 4: NPC Implementation ### Step 4.1: Intermediate NPC **Create:** `Core/Game/GinRummy/AI/GinRummyNPC.cs` ```csharp using System; using System.Collections.Generic; using System.Linq; using CardsFramework.Cards; namespace GinRummy { /// /// Intermediat NPC for Gin Rummy /// public class GinRummyNPC { private GinRummyPlayer player; private Random random; public GinRummyNPC(GinRummyPlayer player) { this.player = player; this.random = new Random(); } /// /// Decides whether to draw from stock or discard pile /// public bool ShouldDrawFromDiscard(TraditionalCard topDiscard) { if (topDiscard == null) return false; // Simulate adding this card to hand List testHand = GetCurrentHandCards(); testHand.Add(topDiscard); // Find melds with this card var meldsWithDiscard = MeldDetector.FindOptimalMelds(testHand); int deadwoodWithDiscard = MeldDetector.CalculateDeadwood(testHand, meldsWithDiscard); // Compare to current deadwood return deadwoodWithDiscard < player.Deadwood; } /// /// Decides which card to discard /// public TraditionalCard SelectCardToDiscard() { List handCards = GetCurrentHandCards(); // Analyze hand var melds = MeldDetector.FindOptimalMelds(handCards); var meldedCards = GetMeldedCards(melds); // Get deadwood cards var deadwoodCards = handCards.Where(card => !meldedCards.Contains(card)).ToList(); if (deadwoodCards.Count > 0) { // Discard highest value deadwood card return deadwoodCards.OrderByDescending(card => Meld.GetCardPoints(card)).First(); } else { // No deadwood - discard card that breaks the smallest meld return SelectCardFromMelds(melds); } } /// /// Decides whether to knock /// public bool ShouldKnock() { // Knock if deadwood is very low if (player.HasGin) return true; if (player.Deadwood <= 5) return true; // Knock with higher deadwood with some probability if (player.Deadwood <= 10) { // 50% chance if deadwood is 6-10 return random.NextDouble() < 0.5; } return false; } /// /// Evaluates how useful a card is for melds /// private int EvaluateCardUsefulness(TraditionalCard card, List hand) { int usefulness = 0; // Check for potential sets (same rank) int sameRankCount = hand.Count(c => c.Value == card.Value); usefulness += sameRankCount * 10; // Check for potential runs (consecutive same suit) var sameSuit = hand.Where(c => c.Type == card.Type).ToList(); foreach (var otherCard in sameSuit) { int valueDiff = Math.Abs( GetCardNumericValue(card) - GetCardNumericValue(otherCard)); if (valueDiff == 1) usefulness += 15; // Adjacent card else if (valueDiff == 2) usefulness += 5; // One card away } return usefulness; } /// /// Selects a card to discard from melds (when no deadwood) /// private TraditionalCard SelectCardFromMelds(List melds) { // Find the smallest meld var smallestMeld = melds.OrderBy(m => m.Cards.Count).First(); // Discard highest value card from smallest meld return smallestMeld.Cards.OrderByDescending(card => Meld.GetCardPoints(card)).First(); } /// /// Gets all cards currently in melds /// private HashSet GetMeldedCards(List melds) { var meldedCards = new HashSet(); foreach (var meld in melds) { foreach (var card in meld.Cards) { meldedCards.Add(card); } } return meldedCards; } /// /// Gets current hand as list /// private List GetCurrentHandCards() { List cards = new List(); for (int i = 0; i < player.Hand.Count; i++) { cards.Add(player.Hand[i]); } return cards; } /// /// Gets numeric value for card /// private int GetCardNumericValue(TraditionalCard card) { switch (card.Value) { case CardValue.Ace: return 1; case CardValue.Two: return 2; case CardValue.Three: return 3; case CardValue.Four: return 4; case CardValue.Five: return 5; case CardValue.Six: return 6; case CardValue.Seven: return 7; case CardValue.Eight: return 8; case CardValue.Nine: return 9; case CardValue.Ten: return 10; case CardValue.Jack: return 11; case CardValue.Queen: return 12; case CardValue.King: return 13; default: return 0; } } } } ``` **NPC Intelligence Strategy:** 1. **Drawing:** Takes discard if it reduces deadwood 2. **Discarding:** Discards highest-value deadwood card 3. **Knocking:** Knocks with Gin or very low deadwood, probabilistic for medium deadwood This creates a competent but not unbeatable opponent. --- ## Part 5: Game Rules ### Step 5.1: Knock Rule **Create:** `Core/Game/GinRummy/Rules/KnockRule.cs` ```csharp using System; using CardsFramework.Rules; namespace GinRummy { public class KnockEventArgs : EventArgs { public GinRummyPlayer Player { get; set; } } /// /// Rule that fires when a player knocks /// public class KnockRule : GameRule { private readonly GinRummyCardGame game; public KnockRule(GinRummyCardGame game) { this.game = game; } public override void Check() { foreach (var player in game.Players) { if (player is GinRummyPlayer ginPlayer) { if (ginPlayer.HasKnocked && !ginPlayer.HasGin) { FireRuleMatch(new KnockEventArgs { Player = ginPlayer }); return; } } } } } } ``` ### Step 5.2: Gin Rule **Create:** `Core/Game/GinRummy/Rules/GinRule.cs` ```csharp using System; using CardsFramework.Rules; namespace GinRummy { public class GinEventArgs : EventArgs { public GinRummyPlayer Player { get; set; } } /// /// Rule that fires when a player gets Gin /// public class GinRule : GameRule { private readonly GinRummyCardGame game; public GinRule(GinRummyCardGame game) { this.game = game; } public override void Check() { foreach (var player in game.Players) { if (player is GinRummyPlayer ginPlayer) { if (ginPlayer.HasGin) { FireRuleMatch(new GinEventArgs { Player = ginPlayer }); return; } } } } } } ``` ### Step 5.3: Turn Complete Rule **Create:** `Core/Game/GinRummy/Rules/TurnCompleteRule.cs` ```csharp using System; using CardsFramework.Rules; namespace GinRummy { public class TurnCompleteEventArgs : EventArgs { public GinRummyPlayer Player { get; set; } } /// /// Rule that fires when a player completes their turn /// public class TurnCompleteRule : GameRule { private readonly GinRummyCardGame game; private int previousHandCount = -1; public TurnCompleteRule(GinRummyCardGame game) { this.game = game; } public override void Check() { var currentPlayer = game.GetCurrentGinRummyPlayer(); if (currentPlayer != null && currentPlayer.IsMyTurn) { // Turn is complete when player has drawn and discarded // (hand back to 10 cards after temporarily having 11) if (currentPlayer.HasDrawn && currentPlayer.Hand.Count == 10) { if (previousHandCount == 11) { FireRuleMatch(new TurnCompleteEventArgs { Player = currentPlayer }); previousHandCount = 10; } } else if (currentPlayer.Hand.Count == 11) { previousHandCount = 11; } } } } } ``` **How It Works:** - Detects when hand goes from 11 cards (after draw) to 10 cards (after discard) - Signals turn completion to advance to next player --- ## Part 6: Main Game Class (Part 1/3) ### Step 6.1: Fields and Initialization **Create:** `Core/Game/GinRummy/Game/GinRummyCardGame.cs` ```csharp using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using CardsFramework.Cards; using CardsFramework.Game; using CardsFramework.Players; using CardsFramework.UI; using CardsFramework.Rules; using CardsFramework.Core; namespace GinRummy { public class GinRummyCardGame : CardsGame { #region Fields // Game state private GinRummyGameState currentState; public GinRummyGameState State { get { return currentState; } set { currentState = value; } } // Stock and discard piles private List discardPile; public TraditionalCard TopDiscard { get { return discardPile.Count > 0 ? discardPile[discardPile.Count - 1] : null; } } // UI Components private List animatedHands; private AnimatedCardsGameComponent discardPileComponent; private DeckDisplayComponent stockPileComponent; private Button buttonDrawStock; private Button buttonDrawDiscard; private Button buttonKnock; private Button buttonGin; private Button buttonNewRound; // Current turn management private int currentPlayerIndex; // Game rules private KnockRule knockRule; private GinRule ginRule; private TurnCompleteRule turnCompleteRule; // Display private SpriteFont gameFont; private string statusText; // Screen management private ScreenManager screenManager; #endregion #region Initialization /// /// Creates a new Gin Rummy game /// /// The game table for layout /// The screen manager for SpriteBatch and Content public GinRummyCardGame(GameTable gameTable, ScreenManager screenManager) : base( decks: 1, jokersInDeck: 0, suits: CardSuit.AllSuits, cardValues: CardValue.NonJokers, minimumPlayers: 2, maximumPlayers: 4, gameTable: gameTable, theme: "Default") { this.screenManager = screenManager; discardPile = new List(); animatedHands = new List(); currentPlayerIndex = 0; statusText = ""; } public override void Initialize() { base.Initialize(); currentState = GinRummyGameState.Dealing; } public override void LoadContent() { base.LoadContent(); // Use font from ScreenManager gameFont = screenManager.Font; // Get input state for buttons InputState input = new InputState(); int screenWidth = GraphicsDevice.Viewport.Width; int screenHeight = GraphicsDevice.Viewport.Height; int buttonWidth = 200; int buttonHeight = 60; // Create buttons using the actual Button constructor // Buttons use texture names, not Texture2D objects // Draw Stock button buttonDrawStock = new Button( "ButtonRegular", "ButtonPressed", input, this, screenManager.SpriteBatch, screenManager.GlobalTransformation ); buttonDrawStock.Text = "Draw from Stock"; buttonDrawStock.Font = gameFont; buttonDrawStock.Bounds = new Rectangle(screenWidth / 2 - buttonWidth - 120, screenHeight - 150, buttonWidth, buttonHeight); buttonDrawStock.Click += ButtonDrawStock_Click; buttonDrawStock.Visible = false; Game.Components.Add(buttonDrawStock); // Draw Discard button buttonDrawDiscard = new Button( "ButtonRegular", "ButtonPressed", input, this, screenManager.SpriteBatch, screenManager.GlobalTransformation ); buttonDrawDiscard.Text = "Draw from Discard"; buttonDrawDiscard.Font = gameFont; buttonDrawDiscard.Bounds = new Rectangle(screenWidth / 2 + 120 - buttonWidth, screenHeight - 150, buttonWidth, buttonHeight); buttonDrawDiscard.Click += ButtonDrawDiscard_Click; buttonDrawDiscard.Visible = false; Game.Components.Add(buttonDrawDiscard); // Knock button buttonKnock = new Button( "ButtonRegular", "ButtonPressed", input, this, screenManager.SpriteBatch, screenManager.GlobalTransformation ); buttonKnock.Text = "Knock"; buttonKnock.Font = gameFont; buttonKnock.Bounds = new Rectangle(screenWidth / 2 - buttonWidth / 2, screenHeight - 230, buttonWidth, buttonHeight); buttonKnock.Color = Color.Yellow; buttonKnock.Click += ButtonKnock_Click; buttonKnock.Visible = false; Game.Components.Add(buttonKnock); // Gin button buttonGin = new Button( "ButtonRegular", "ButtonPressed", input, this, screenManager.SpriteBatch, screenManager.GlobalTransformation ); buttonGin.Text = "Gin!"; buttonGin.Font = gameFont; buttonGin.Bounds = new Rectangle(screenWidth / 2 - buttonWidth / 2, screenHeight - 310, buttonWidth, buttonHeight); buttonGin.Color = Color.Green; buttonGin.Click += ButtonGin_Click; buttonGin.Visible = false; Game.Components.Add(buttonGin); // New Round button buttonNewRound = new Button( "ButtonRegular", "ButtonPressed", input, this, screenManager.SpriteBatch, screenManager.GlobalTransformation ); buttonNewRound.Text = "New Round"; buttonNewRound.Font = gameFont; buttonNewRound.Bounds = new Rectangle(screenWidth / 2 - buttonWidth / 2, screenHeight - 150, buttonWidth, buttonHeight); buttonNewRound.Color = Color.LightBlue; buttonNewRound.Click += ButtonNewRound_Click; buttonNewRound.Visible = false; Game.Components.Add(buttonNewRound); // Create stock pile display stockPileComponent = new DeckDisplayComponent(dealer, gameTable, Game); stockPileComponent.LoadContent(); Game.Components.Add(stockPileComponent); // Initialize rules knockRule = new KnockRule(this); knockRule.RuleMatch += KnockRule_RuleMatch; Rules.Add(knockRule); ginRule = new GinRule(this); ginRule.RuleMatch += GinRule_RuleMatch; Rules.Add(ginRule); turnCompleteRule = new TurnCompleteRule(this); turnCompleteRule.RuleMatch += TurnCompleteRule_RuleMatch; Rules.Add(turnCompleteRule); } #endregion ``` ### Step 6.2: Player Management ```csharp #region Player Management public override void AddPlayer(Player newPlayer) { if (!(newPlayer is GinRummyPlayer)) { throw new ArgumentException("Player must be of type GinRummyPlayer"); } base.AddPlayer(newPlayer); // Create animated hand for this player AnimatedHandGameComponent animatedHand = new AnimatedHandGameComponent( Players.Count - 1, // Place/position index newPlayer.Hand, this, // CardsGame screenManager.SpriteBatch, screenManager.GlobalTransformation ); animatedHand.LoadContent(); animatedHands.Add(animatedHand); Game.Components.Add(animatedHand); } public override Player GetCurrentPlayer() { if (Players.Count == 0) return null; return Players[currentPlayerIndex]; } public GinRummyPlayer GetCurrentGinRummyPlayer() { return GetCurrentPlayer() as GinRummyPlayer; } private void NextPlayer() { var currentPlayer = GetCurrentGinRummyPlayer(); if (currentPlayer != null) { currentPlayer.IsMyTurn = false; currentPlayer.HasDrawn = false; } currentPlayerIndex = (currentPlayerIndex + 1) % Players.Count; var nextPlayer = GetCurrentGinRummyPlayer(); if (nextPlayer != null) { nextPlayer.IsMyTurn = true; } statusText = $"{nextPlayer.Name}'s turn"; } #endregion ``` ### Step 6.3: Dealing ```csharp #region Card Management public override void Deal() { // Shuffle deck dealer.Shuffle(); // Deal 10 cards to each player for (int i = 0; i < 10; i++) { foreach (var player in Players) { dealer[0].MoveToHand(player.Hand); } } // Flip top card to start discard pile TraditionalCard topCard = dealer[0]; discardPile.Add(topCard); // Create discard pile component Vector2 discardPosition = new Vector2( GraphicsDevice.Viewport.Width / 2 + 100, GraphicsDevice.Viewport.Height / 2 ); discardPileComponent = new AnimatedCardsGameComponent( topCard, this, screenManager.SpriteBatch, screenManager.GlobalTransformation ); discardPileComponent.CurrentPosition = discardPosition; discardPileComponent.IsFaceDown = false; discardPileComponent.LoadContent(); Game.Components.Add(discardPileComponent); // Analyze all hands foreach (var player in Players) { if (player is GinRummyPlayer ginPlayer) { ginPlayer.AnalyzeHand(); } } // Start first player's turn var firstPlayer = GetCurrentGinRummyPlayer(); if (firstPlayer != null) { firstPlayer.IsMyTurn = true; statusText = $"{firstPlayer.Name}'s turn"; } currentState = GinRummyGameState.Drawing; } public void DrawFromStock() { var currentPlayer = GetCurrentGinRummyPlayer(); if (currentPlayer == null || !currentPlayer.IsMyTurn) return; if (dealer.Count > 0) { TraditionalCard card = dealer[0]; card.MoveToHand(currentPlayer.Hand); currentPlayer.HasDrawn = true; currentPlayer.AnalyzeHand(); currentState = GinRummyGameState.Discarding; } } public void DrawFromDiscard() { var currentPlayer = GetCurrentGinRummyPlayer(); if (currentPlayer == null || !currentPlayer.IsMyTurn) return; if (discardPile.Count > 0) { TraditionalCard card = discardPile[discardPile.Count - 1]; discardPile.RemoveAt(discardPile.Count - 1); card.MoveToHand(currentPlayer.Hand); currentPlayer.HasDrawn = true; currentPlayer.AnalyzeHand(); // Update discard pile visual UpdateDiscardPileVisual(); currentState = GinRummyGameState.Discarding; } } public void DiscardCard(TraditionalCard card) { var currentPlayer = GetCurrentGinRummyPlayer(); if (currentPlayer == null || !currentPlayer.IsMyTurn || !currentPlayer.HasDrawn) return; // Remove from hand currentPlayer.Hand.LostCard -= null; // Detach events if any discardPile.Add(card); // Update visual UpdateDiscardPileVisual(); // Analyze hand after discard currentPlayer.AnalyzeHand(); // Check if player can knock or has gin if (currentPlayer.HasGin) { currentState = GinRummyGameState.Gin; } else { currentState = GinRummyGameState.Waiting; } } private void UpdateDiscardPileVisual() { if (discardPileComponent != null) { Game.Components.Remove(discardPileComponent); } if (TopDiscard != null) { Vector2 discardPosition = new Vector2( GraphicsDevice.Viewport.Width / 2 + 100, GraphicsDevice.Viewport.Height / 2 ); discardPileComponent = new AnimatedCardsGameComponent(TopDiscard, Game); discardPileComponent.CurrentPosition = discardPosition; discardPileComponent.IsFaceDown = false; discardPileComponent.LoadContent(); Game.Components.Add(discardPileComponent); } } #endregion ``` --- ## Part 7: Main Game Class (Part 2/3) - Game Flow ```csharp #region Game Flow public override void StartPlaying() { currentState = GinRummyGameState.Dealing; Deal(); } public override void Update(GameTime gameTime) { base.Update(gameTime); CheckRules(); UpdateUIForState(); ProcessAITurns(gameTime); } private void UpdateUIForState() { // Hide all buttons initially buttonDrawStock.Visible = false; buttonDrawDiscard.Visible = false; buttonKnock.Visible = false; buttonGin.Visible = false; buttonNewRound.Visible = false; var currentPlayer = GetCurrentGinRummyPlayer(); switch (currentState) { case GinRummyGameState.Drawing: // Only show draw buttons for human player if (currentPlayer != null && !(currentPlayer is GinRummyNPCPlayer)) { buttonDrawStock.Visible = true; buttonDrawDiscard.Visible = (TopDiscard != null); } break; case GinRummyGameState.Discarding: // Show knock/gin buttons if eligible if (currentPlayer != null && !(currentPlayer is GinRummyNPCPlayer)) { if (currentPlayer.HasGin) { buttonGin.Visible = true; } else if (currentPlayer.CanKnock()) { buttonKnock.Visible = true; } statusText = $"{currentPlayer.Name}: Select a card to discard"; } break; case GinRummyGameState.RoundEnd: buttonNewRound.Visible = true; break; } } private void ProcessAITurns(GameTime gameTime) { var currentPlayer = GetCurrentGinRummyPlayer(); if (currentPlayer == null || !(currentPlayer is GinRummyNPCPlayer)) return; GinRummyNPCPlayer NPCPlayer = (GinRummyNPCPlayer)currentPlayer; // NPC drawing phase if (currentState == GinRummyGameState.Drawing) { // Small delay for realism System.Threading.Tasks.Task.Delay(1000).ContinueWith(t => { if (NPCPlayer.AI.ShouldDrawFromDiscard(TopDiscard)) { DrawFromDiscard(); } else { DrawFromStock(); } }); } // NPC discarding phase else if (currentState == GinRummyGameState.Discarding) { // Check if NPC should knock if (NPCPlayer.AI.ShouldKnock()) { if (NPCPlayer.HasGin) { NPCPlayer.HasGin = true; currentState = GinRummyGameState.Gin; } else { NPCPlayer.HasKnocked = true; currentState = GinRummyGameState.Knocked; } return; } // Select card to discard System.Threading.Tasks.Task.Delay(1000).ContinueWith(t => { TraditionalCard cardToDiscard = NPCPlayer.AI.SelectCardToDiscard(); DiscardCard(cardToDiscard); }); } } #endregion ``` --- ## Part 8: Main Game Class (Part 3/3) - Event Handlers and Scoring ```csharp #region Event Handlers private void ButtonDrawStock_Click(object sender, EventArgs e) { DrawFromStock(); } private void ButtonDrawDiscard_Click(object sender, EventArgs e) { DrawFromDiscard(); } private void ButtonKnock_Click(object sender, EventArgs e) { var currentPlayer = GetCurrentGinRummyPlayer(); if (currentPlayer != null && currentPlayer.CanKnock()) { currentPlayer.HasKnocked = true; currentState = GinRummyGameState.Knocked; } } private void ButtonGin_Click(object sender, EventArgs e) { var currentPlayer = GetCurrentGinRummyPlayer(); if (currentPlayer != null && currentPlayer.HasGin) { currentState = GinRummyGameState.Gin; } } private void ButtonNewRound_Click(object sender, EventArgs e) { ResetForNewRound(); StartPlaying(); } private void KnockRule_RuleMatch(object sender, EventArgs e) { KnockEventArgs args = (KnockEventArgs)e; statusText = $"{args.Player.Name} knocked!"; currentState = GinRummyGameState.Scoring; CalculateScores(); } private void GinRule_RuleMatch(object sender, EventArgs e) { GinEventArgs args = (GinEventArgs)e; statusText = $"{args.Player.Name} got Gin!"; currentState = GinRummyGameState.Scoring; CalculateScores(); } private void TurnCompleteRule_RuleMatch(object sender, EventArgs e) { NextPlayer(); currentState = GinRummyGameState.Drawing; } #endregion #region Scoring private void CalculateScores() { var knocker = Players.OfType().FirstOrDefault(p => p.HasKnocked || p.HasGin); if (knocker == null) return; // Get opponents var opponents = Players.OfType().Where(p => p != knocker).ToList(); if (knocker.HasGin) { // Gin: Knocker gets all opponents' deadwood + 25 bonus int totalOpponentDeadwood = opponents.Sum(opp => opp.Deadwood); knocker.RoundScore = totalOpponentDeadwood + 25; statusText = $"{knocker.Name} wins with Gin!\n" + $"Score: {knocker.RoundScore} points"; } else { // Regular knock: Check for undercut bool undercut = false; foreach (var opponent in opponents) { if (opponent.Deadwood <= knocker.Deadwood) { // Undercut! Opponent wins undercut = true; int difference = knocker.Deadwood - opponent.Deadwood; opponent.RoundScore = difference + 25; statusText = $"{opponent.Name} undercuts {knocker.Name}!\n" + $"{opponent.Name} scores {opponent.RoundScore} points"; break; } } if (!undercut) { // Knocker wins int totalDifference = opponents.Sum(opp => opp.Deadwood) - knocker.Deadwood; knocker.RoundScore = totalDifference; statusText = $"{knocker.Name} wins!\n" + $"Score: {knocker.RoundScore} points"; } } currentState = GinRummyGameState.RoundEnd; } private void ResetForNewRound() { // Clear discard pile discardPile.Clear(); // Remove visual components if (discardPileComponent != null) { Game.Components.Remove(discardPileComponent); } // Reset all players foreach (var player in Players) { if (player is GinRummyPlayer ginPlayer) { // Clear hand while (ginPlayer.Hand.Count > 0) { ginPlayer.Hand[0].MoveToHand(dealer); } ginPlayer.ResetForNewRound(); } } // Reshuffle dealer dealer.Shuffle(); currentPlayerIndex = 0; statusText = ""; } #endregion #region Utilities public override int CardValue(TraditionalCard card) { return Meld.GetCardPoints(card); } public override void Draw(GameTime gameTime) { base.Draw(gameTime); // Draw status text if (!string.IsNullOrEmpty(statusText) && gameFont != null) { SpriteBatch spriteBatch = (SpriteBatch)Game.Services.GetService(typeof(SpriteBatch)); if (spriteBatch != null) { Vector2 position = new Vector2(20, 20); // Draw with shadow spriteBatch.DrawString(gameFont, statusText, position + new Vector2(2, 2), Color.Black); spriteBatch.DrawString(gameFont, statusText, position, Color.White); } } } #endregion } } ``` --- ## Part 9: UI Enhancement - Hand Organizer **Create:** `Core/Game/GinRummy/UI/HandOrganizer.cs` ```csharp using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; using CardsFramework.Cards; namespace GinRummy { /// /// Organizes cards in hand for better visualization /// public class HandOrganizer { /// /// Sorts and groups cards for display /// public static List OrganizeHand(List cards, List melds) { List organized = new List(); // Get cards in melds var meldedCards = new HashSet(); foreach (var meld in melds) { foreach (var card in meld.Cards) { meldedCards.Add(card); } } // Add melded cards first (grouped by meld) foreach (var meld in melds) { organized.AddRange(meld.Cards); } // Add remaining cards (deadwood) sorted var deadwood = cards.Where(c => !meldedCards.Contains(c)) .OrderBy(c => c.Type) .ThenBy(c => GetCardNumericValue(c)) .ToList(); organized.AddRange(deadwood); return organized; } private static int GetCardNumericValue(TraditionalCard card) { switch (card.Value) { case CardValue.Ace: return 1; case CardValue.Two: return 2; case CardValue.Three: return 3; case CardValue.Four: return 4; case CardValue.Five: return 5; case CardValue.Six: return 6; case CardValue.Seven: return 7; case CardValue.Eight: return 8; case CardValue.Nine: return 9; case CardValue.Ten: return 10; case CardValue.Jack: return 11; case CardValue.Queen: return 12; case CardValue.King: return 13; default: return 0; } } /// /// Calculates card positions for visual display /// public static List CalculateCardPositions( int cardCount, Vector2 startPosition, float cardSpacing, List meldBreaks = null) { List positions = new List(); float currentX = startPosition.X; for (int i = 0; i < cardCount; i++) { positions.Add(new Vector2(currentX, startPosition.Y)); // Add extra space between melds if (meldBreaks != null && meldBreaks.Contains(i)) { currentX += cardSpacing + 20; // Extra gap } else { currentX += cardSpacing; } } return positions; } } } ``` This helper organizes cards visually, grouping melds together and separating deadwood. --- ## Part 10: Screen Integration ### Step 10.1: Create Gameplay Screen **Create:** `Core/Game/Screens/GinRummyGameplayScreen.cs` ```csharp using System; using Microsoft.Xna.Framework; using GinRummy; using CardsFramework.UI; using CardsFramework.Core; namespace CardsStarterKit { public class GinRummyGameplayScreen : GameScreen { private GinRummyCardGame ginRummyGame; public GinRummyGameplayScreen() { EnabledGestures = Microsoft.Xna.Framework.Input.Touch.GestureType.Tap; } public override void LoadContent() { base.LoadContent(); // Create game table (4 player positions) GameTable gameTable = new GameTable(ScreenManager.Game, 4); // Create game ginRummyGame = new GinRummyCardGame(gameTable, ScreenManager); ScreenManager.Game.Components.Add(ginRummyGame); ginRummyGame.Initialize(); ginRummyGame.LoadContent(); // Add players GinRummyPlayer humanPlayer = new GinRummyPlayer("You", ginRummyGame); ginRummyGame.AddPlayer(humanPlayer); GinRummyNPCPlayer npc1 = new GinRummyNPCPlayer("NPC 1", ginRummyGame); ginRummyGame.AddPlayer(npc1); GinRummyNPCPlayer npc2 = new GinRummyNPCPlayer("NPC 2", ginRummyGame); ginRummyGame.AddPlayer(npc2); // Start game ginRummyGame.StartPlaying(); } public override void HandleInput(InputState input) { base.HandleInput(input); if (input.IsPauseGame(null)) { ScreenManager.AddScreen(new PauseScreen(), null); } // Handle card selection for discarding HandleCardSelection(input); } private void HandleCardSelection(InputState input) { if (ginRummyGame.State != GinRummyGameState.Discarding) return; var currentPlayer = ginRummyGame.GetCurrentGinRummyPlayer(); if (currentPlayer == null || currentPlayer is GinRummyNPCPlayer) return; // Check for tap on cards in hand // (Implementation depends on your touch/mouse handling) // You would iterate through animated hand components and check bounds } public override void UnloadContent() { if (ginRummyGame != null) { ScreenManager.Game.Components.Remove(ginRummyGame); } base.UnloadContent(); } } } ``` ### Step 10.2: Add Menu Entry **Modify:** `Core/Game/Screens/MainMenuScreen.cs` ```csharp // Add in constructor MenuEntry ginRummyMenuEntry = new MenuEntry("Gin Rummy"); ginRummyMenuEntry.Selected += GinRummyMenuEntrySelected; menuEntries.Add(ginRummyMenuEntry); // Add event handler private void GinRummyMenuEntrySelected(object sender, EventArgs e) { ScreenManager.AddScreen(new GinRummyGameplayScreen(), null); } ``` --- ## Part 11: Testing ### Step 11.1: Build and Run ```bash dotnet build # For macOS/Linux dotnet run --project Platforms/DesktopGL/CardsStarterKit.DesktopGL.csproj # For Windows dotnet run --project Platforms/WindowsDX/CardsStarterKit.WindowsDX.csproj ``` ### Step 11.2: Test Scenarios 1. **Basic Gameplay:** - Draw from stock/discard - Discard cards - Verify turn rotation 2. **Meld Detection:** - Check that sets are recognized (3 7s, etc.) - Check that runs are recognized (4♠ 5♠ 6♠) - Verify deadwood calculation 3. **Knocking:** - Get deadwood ≤ 10 and test knock - Verify scoring 4. **Gin:** - Form all melds (0 deadwood) - Verify Gin declaration and bonus 5. **NPC Behavior:** - Watch NPC draw and discard decisions - Verify NPC knocks appropriately --- ## Part 12: Enhancements and Next Steps ### Enhancement 1: Card Clicking for Human Player Add touch/mouse handling in the screen to let human players click cards to discard: ```csharp private void HandleCardSelection(InputState input) { // Get tap position foreach (var gesture in input.Gestures) { if (gesture.GestureType == GestureType.Tap) { // Check which card was tapped // (Iterate through animated hand components and check bounds) } } } ``` ### Enhancement 2: Visual Meld Highlighting Highlight cards that are part of melds: ```csharp // In AnimatedHandGameComponent or custom renderer: foreach (var card in meldedCards) { // Draw green border or glow effect } ``` ### Enhancement 3: Multi-Round Scoring To extend to full matches (first to 100 points): ```csharp // Add to GinRummyPlayer: public int MatchScore { get; set; } // Add game state: public enum GinRummyGameState { // ... existing states MatchEnd // When someone reaches 100 } // After each round: knocker.MatchScore += knocker.RoundScore; if (knocker.MatchScore >= 100) { currentState = GinRummyGameState.MatchEnd; } ``` ### Enhancement 4: Laying Off Cards After a knock, allow opponents to add cards to knocker's melds: ```csharp public class LayOffPhase { public static List FindLayOffCards( List hand, List opponentMelds) { // Check each hand card to see if it extends opponent's melds // ... } } ``` ### Enhancement 5: Better NPC Intelligence Advanced NPC improvements: - Track discarded cards (card counting) - Infer opponent hands from discards - Calculate probability of completing melds - Defensive discarding (don't give opponent useful cards) --- ## Key Takeaways ### What You Learned 1. **Complex State Management:** - Multi-phase turns (draw → discard) - Turn rotation among players - Handling knocking and gin conditions 2. **Meld Detection Algorithm:** - Finding all possible melds - Combinatorial optimization to minimize deadwood - Backtracking algorithm 3. **NPC Implementation:** - Evaluating card usefulness - Making draw/discard decisions - Probabilistic knocking strategy 4. **Scoring Logic:** - Standard knock scoring - Gin bonuses - Undercut detection 5. **Multi-Player Support:** - Turn management - Player indexing - Mixed human/NPC Players ### Design Patterns Used 1. **State Machine:** GinRummyGameState controls game flow 2. **Strategy Pattern:** NPC decision-making in separate class 3. **Rule Pattern:** Knock, Gin, TurnComplete rules 4. **Component Pattern:** Animated hands, cards, deck display 5. **Observer Pattern:** Event-driven rule matching --- ## Troubleshooting ### Melds Not Detected - Debug `MeldDetector.FindOptimalMelds()` - Print all possible melds before optimization - Check card sorting logic ### NPC Makes Bad Moves - Add logging to NPC decision methods - Print deadwood calculations - Verify card evaluation logic ### Turn Doesn't Advance - Check `TurnCompleteRule` hand count tracking - Verify `NextPlayer()` is called - Debug state transitions ### Scoring Incorrect - Print each player's deadwood - Verify meld point calculations - Check undercut logic --- ## Extending to Full Matches To implement full Gin Rummy matches to 100 points: **1. Add Match Tracking:** ```csharp public int MatchScore { get; set; } public int RoundsWon { get; set; } ``` **2. Add Match State:** ```csharp public enum MatchPhase { InProgress, Complete } ``` **3. Check After Each Round:** ```csharp if (winner.MatchScore >= 100) { currentState = GinRummyGameState.MatchEnd; ShowMatchResults(); } else { PrepareNextRound(); } ``` **4. Add Bonuses:** - Game bonus: +100 for winning match - Shutout bonus: Additional points if opponent scored 0 --- ## Related Gin Rummy Variants Want to explore other variants? Here are some links: **Oklahoma Gin:** - First upcard determines knock limit (not always 10) - https://en.wikipedia.org/wiki/Oklahoma_gin **Hollywood Gin:** - Multiple simultaneous games - Wins count toward different game tracks - https://www.pagat.com/rummy/ginrummy.html#hollywood **Straight Gin:** - Can only knock with Gin (0 deadwood) - Higher scoring, faster games - https://www.pagat.com/rummy/ginrummy.html#straight --- ## Complete File Checklist - [ ] `Core/Game/GinRummy/Game/GinRummyGameState.cs` - [ ] `Core/Game/GinRummy/Game/Meld.cs` - [ ] `Core/Game/GinRummy/Game/MeldDetector.cs` - [ ] `Core/Game/GinRummy/Game/GinRummyCardGame.cs` - [ ] `Core/Game/GinRummy/Players/GinRummyPlayer.cs` - [ ] `Core/Game/GinRummy/Players/GinRummyNPCPlayer.cs` - [ ] `Core/Game/GinRummy/AI/GinRummyNPC.cs` - [ ] `Core/Game/GinRummy/Rules/KnockRule.cs` - [ ] `Core/Game/GinRummy/Rules/GinRule.cs` - [ ] `Core/Game/GinRummy/Rules/TurnCompleteRule.cs` - [ ] `Core/Game/GinRummy/UI/HandOrganizer.cs` - [ ] `Core/Game/Screens/GinRummyGameplayScreen.cs` - [ ] Modified `Core/Game/Screens/MainMenuScreen.cs` --- ## Conclusion Congratulations! You've built a complete Gin Rummy implementation with: - Full game rules (melds, knocking, gin) - Intelligent NPC opponents - Multi-player support - Proper scoring - Turn-based gameplay This tutorial demonstrated advanced card game concepts including meld detection algorithms, NPC decision-making, and complex game state management. You're now equipped to build any card game using the CardsStarterKit framework! **Next challenges to try:** - Implement other Rummy variants (Rummy 500, Canasta) - Build trick-taking games (Hearts, Spades, Bridge) - Create shedding games (Crazy Eights, Uno-style) Happy coding!