# 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!