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:
Target Audience: MonoGame developers new to card games
Estimated Time: 6-8 hours
Difficulty: Intermediate
Objective: Form melds (sets/runs) and minimize deadwood (unmatched cards).
Setup:
Gameplay Flow:
Melds:
Deadwood Points:
Scoring (Single Round - Tutorial Focus):
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).
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
Create: Core/Game/GinRummy/Game/GinRummyGameState.cs
namespace GinRummy
{
/// <summary>
/// States of a Gin Rummy game
/// </summary>
public enum GinRummyGameState
{
/// <summary>
/// Dealing initial hands
/// </summary>
Dealing,
/// <summary>
/// Player's turn - drawing phase
/// </summary>
Drawing,
/// <summary>
/// Player's turn - discarding phase
/// </summary>
Discarding,
/// <summary>
/// Player knocked - showing hands
/// </summary>
Knocked,
/// <summary>
/// Player got Gin - showing hands
/// </summary>
Gin,
/// <summary>
/// Calculating scores
/// </summary>
Scoring,
/// <summary>
/// Round complete - showing results
/// </summary>
RoundEnd,
/// <summary>
/// Waiting between turns
/// </summary>
Waiting
}
}
Create: Core/Game/GinRummy/Game/Meld.cs
using System.Collections.Generic;
using System.Linq;
using CardsFramework.Cards;
namespace GinRummy
{
/// <summary>
/// Types of melds in Gin Rummy
/// </summary>
public enum MeldType
{
/// <summary>
/// 3+ cards of same rank (e.g., 7♥ 7♠ 7♣)
/// </summary>
Set,
/// <summary>
/// 3+ consecutive cards of same suit (e.g., 4♠ 5♠ 6♠)
/// </summary>
Run
}
/// <summary>
/// Represents a meld (set or run) of cards
/// </summary>
public class Meld
{
/// <summary>
/// Type of this meld
/// </summary>
public MeldType Type { get; set; }
/// <summary>
/// Cards in this meld
/// </summary>
public List<TraditionalCard> Cards { get; set; }
/// <summary>
/// Total point value of cards in this meld
/// </summary>
public int PointValue
{
get
{
return Cards.Sum(card => GetCardPoints(card));
}
}
public Meld()
{
Cards = new List<TraditionalCard>();
}
/// <summary>
/// Creates a meld from a list of cards
/// </summary>
public Meld(MeldType type, List<TraditionalCard> cards)
{
Type = type;
Cards = new List<TraditionalCard>(cards);
}
/// <summary>
/// Gets the point value of a card for deadwood calculation
/// </summary>
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;
}
}
/// <summary>
/// Checks if this meld is valid
/// </summary>
public bool IsValid()
{
if (Cards.Count < 3)
return false;
if (Type == MeldType.Set)
return IsValidSet();
else
return IsValidRun();
}
/// <summary>
/// Validates a set (same rank)
/// </summary>
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);
}
/// <summary>
/// Validates a run (consecutive same suit)
/// </summary>
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;
}
/// <summary>
/// Gets numeric value for sorting (Ace=1, King=13)
/// </summary>
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 scoringGetCardPoints(): Standard Gin Rummy point valuesThis is the brain of Gin Rummy - finding optimal melds to minimize deadwood.
Create: Core/Game/GinRummy/Game/MeldDetector.cs
using System.Collections.Generic;
using System.Linq;
using CardsFramework.Cards;
namespace GinRummy
{
/// <summary>
/// Detects valid melds in a hand and calculates deadwood
/// </summary>
public class MeldDetector
{
/// <summary>
/// Finds the optimal set of melds that minimizes deadwood
/// </summary>
public static List<Meld> FindOptimalMelds(List<TraditionalCard> cards)
{
// Find all possible melds
List<Meld> allPossibleMelds = FindAllPossibleMelds(cards);
// Find combination with minimum deadwood
return FindBestMeldCombination(cards, allPossibleMelds);
}
/// <summary>
/// Finds all possible valid melds in the hand
/// </summary>
private static List<Meld> FindAllPossibleMelds(List<TraditionalCard> cards)
{
List<Meld> melds = new List<Meld>();
// 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;
}
/// <summary>
/// Finds all possible sets in the hand
/// </summary>
private static List<Meld> FindSets(List<TraditionalCard> cards)
{
List<Meld> sets = new List<Meld>();
// 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;
}
/// <summary>
/// Finds all possible runs in the hand
/// </summary>
private static List<Meld> FindRuns(List<TraditionalCard> cards)
{
List<Meld> runs = new List<Meld>();
// 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<TraditionalCard> sequence = new List<TraditionalCard> { 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;
}
/// <summary>
/// Finds the best combination of non-overlapping melds
/// </summary>
private static List<Meld> FindBestMeldCombination(
List<TraditionalCard> allCards,
List<Meld> possibleMelds)
{
List<Meld> bestCombination = new List<Meld>();
int minDeadwood = CalculateDeadwood(allCards, new List<Meld>());
// Try all combinations of melds
FindBestCombinationRecursive(
allCards,
possibleMelds,
new List<Meld>(),
ref bestCombination,
ref minDeadwood);
return bestCombination;
}
/// <summary>
/// Recursive helper to find best non-overlapping meld combination
/// </summary>
private static void FindBestCombinationRecursive(
List<TraditionalCard> allCards,
List<Meld> possibleMelds,
List<Meld> currentCombination,
ref List<Meld> 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<Meld>(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);
}
}
}
/// <summary>
/// Checks if a meld uses any cards already in the combination
/// </summary>
private static bool OverlapsWithCombination(Meld meld, List<Meld> combination)
{
var usedCards = new HashSet<TraditionalCard>();
foreach (var existingMeld in combination)
{
foreach (var card in existingMeld.Cards)
{
usedCards.Add(card);
}
}
return meld.Cards.Any(card => usedCards.Contains(card));
}
/// <summary>
/// Calculates deadwood (unmatched cards) point value
/// </summary>
public static int CalculateDeadwood(List<TraditionalCard> allCards, List<Meld> melds)
{
// Get all cards in melds
var meldedCards = new HashSet<TraditionalCard>();
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;
}
/// <summary>
/// Gets numeric value for card ordering
/// </summary>
private static int GetCardNumericValue(TraditionalCard card)
{
return Meld.GetCardNumericValue(card);
}
}
}
Algorithm Explanation:
This is computationally intensive for 10 cards but acceptable for gameplay.
Create: Core/Game/GinRummy/Players/GinRummyPlayer.cs
using System.Collections.Generic;
using CardsFramework.Cards;
using CardsFramework.Players;
using CardsFramework.Game;
namespace GinRummy
{
/// <summary>
/// Represents a player in Gin Rummy
/// </summary>
public class GinRummyPlayer : Player
{
#region Properties
/// <summary>
/// Current melds in player's hand
/// </summary>
public List<Meld> Melds { get; set; }
/// <summary>
/// Deadwood point value
/// </summary>
public int Deadwood { get; set; }
/// <summary>
/// Whether player has knocked
/// </summary>
public bool HasKnocked { get; set; }
/// <summary>
/// Whether player has gin
/// </summary>
public bool HasGin { get; set; }
/// <summary>
/// Score for this round
/// </summary>
public int RoundScore { get; set; }
/// <summary>
/// Total score across all rounds (for future multi-round support)
/// </summary>
public int TotalScore { get; set; }
/// <summary>
/// Whether it's currently this player's turn
/// </summary>
public bool IsMyTurn { get; set; }
/// <summary>
/// Whether player has drawn this turn
/// </summary>
public bool HasDrawn { get; set; }
#endregion
#region Initialization
public GinRummyPlayer(string name, CardsGame game)
: base(name, game)
{
Melds = new List<Meld>();
Deadwood = 0;
HasKnocked = false;
HasGin = false;
RoundScore = 0;
TotalScore = 0;
IsMyTurn = false;
HasDrawn = false;
}
#endregion
#region Methods
/// <summary>
/// Analyzes hand to find optimal melds and calculate deadwood
/// </summary>
public void AnalyzeHand()
{
List<TraditionalCard> handCards = new List<TraditionalCard>();
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);
}
/// <summary>
/// Checks if player can knock (deadwood <= 10)
/// </summary>
public bool CanKnock()
{
return Deadwood <= 10 && Hand.Count == 10;
}
/// <summary>
/// Resets player state for new round
/// </summary>
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 deadwoodCanKnock(): Checks if knock is legalResetForNewRound(): Prepares for next roundCreate: Core/Game/GinRummy/Players/GinRummyNPCPlayer.cs
using CardsFramework.Game;
namespace GinRummy
{
/// <summary>
/// NPC-controlled Gin Rummy player
/// </summary>
public class GinRummyNPCPlayer : GinRummyPlayer
{
/// <summary>
/// NPC decision-making component
/// </summary>
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.
Create: Core/Game/GinRummy/AI/GinRummyNPC.cs
using System;
using System.Collections.Generic;
using System.Linq;
using CardsFramework.Cards;
namespace GinRummy
{
/// <summary>
/// Intermediat NPC for Gin Rummy
/// </summary>
public class GinRummyNPC
{
private GinRummyPlayer player;
private Random random;
public GinRummyNPC(GinRummyPlayer player)
{
this.player = player;
this.random = new Random();
}
/// <summary>
/// Decides whether to draw from stock or discard pile
/// </summary>
public bool ShouldDrawFromDiscard(TraditionalCard topDiscard)
{
if (topDiscard == null)
return false;
// Simulate adding this card to hand
List<TraditionalCard> 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;
}
/// <summary>
/// Decides which card to discard
/// </summary>
public TraditionalCard SelectCardToDiscard()
{
List<TraditionalCard> 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);
}
}
/// <summary>
/// Decides whether to knock
/// </summary>
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;
}
/// <summary>
/// Evaluates how useful a card is for melds
/// </summary>
private int EvaluateCardUsefulness(TraditionalCard card, List<TraditionalCard> 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;
}
/// <summary>
/// Selects a card to discard from melds (when no deadwood)
/// </summary>
private TraditionalCard SelectCardFromMelds(List<Meld> 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();
}
/// <summary>
/// Gets all cards currently in melds
/// </summary>
private HashSet<TraditionalCard> GetMeldedCards(List<Meld> melds)
{
var meldedCards = new HashSet<TraditionalCard>();
foreach (var meld in melds)
{
foreach (var card in meld.Cards)
{
meldedCards.Add(card);
}
}
return meldedCards;
}
/// <summary>
/// Gets current hand as list
/// </summary>
private List<TraditionalCard> GetCurrentHandCards()
{
List<TraditionalCard> cards = new List<TraditionalCard>();
for (int i = 0; i < player.Hand.Count; i++)
{
cards.Add(player.Hand[i]);
}
return cards;
}
/// <summary>
/// Gets numeric value for card
/// </summary>
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:
This creates a competent but not unbeatable opponent.
Create: Core/Game/GinRummy/Rules/KnockRule.cs
using System;
using CardsFramework.Rules;
namespace GinRummy
{
public class KnockEventArgs : EventArgs
{
public GinRummyPlayer Player { get; set; }
}
/// <summary>
/// Rule that fires when a player knocks
/// </summary>
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;
}
}
}
}
}
}
Create: Core/Game/GinRummy/Rules/GinRule.cs
using System;
using CardsFramework.Rules;
namespace GinRummy
{
public class GinEventArgs : EventArgs
{
public GinRummyPlayer Player { get; set; }
}
/// <summary>
/// Rule that fires when a player gets Gin
/// </summary>
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;
}
}
}
}
}
}
Create: Core/Game/GinRummy/Rules/TurnCompleteRule.cs
using System;
using CardsFramework.Rules;
namespace GinRummy
{
public class TurnCompleteEventArgs : EventArgs
{
public GinRummyPlayer Player { get; set; }
}
/// <summary>
/// Rule that fires when a player completes their turn
/// </summary>
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:
Create: Core/Game/GinRummy/Game/GinRummyCardGame.cs
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<TraditionalCard> discardPile;
public TraditionalCard TopDiscard
{
get { return discardPile.Count > 0 ? discardPile[discardPile.Count - 1] : null; }
}
// UI Components
private List<AnimatedHandGameComponent> 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
/// <summary>
/// Creates a new Gin Rummy game
/// </summary>
/// <param name="gameTable">The game table for layout</param>
/// <param name="screenManager">The screen manager for SpriteBatch and Content</param>
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<TraditionalCard>();
animatedHands = new List<AnimatedHandGameComponent>();
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
#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
#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
#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
#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<GinRummyPlayer>().FirstOrDefault(p => p.HasKnocked || p.HasGin);
if (knocker == null)
return;
// Get opponents
var opponents = Players.OfType<GinRummyPlayer>().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
}
}
Create: Core/Game/GinRummy/UI/HandOrganizer.cs
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using CardsFramework.Cards;
namespace GinRummy
{
/// <summary>
/// Organizes cards in hand for better visualization
/// </summary>
public class HandOrganizer
{
/// <summary>
/// Sorts and groups cards for display
/// </summary>
public static List<TraditionalCard> OrganizeHand(List<TraditionalCard> cards, List<Meld> melds)
{
List<TraditionalCard> organized = new List<TraditionalCard>();
// Get cards in melds
var meldedCards = new HashSet<TraditionalCard>();
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;
}
}
/// <summary>
/// Calculates card positions for visual display
/// </summary>
public static List<Vector2> CalculateCardPositions(
int cardCount,
Vector2 startPosition,
float cardSpacing,
List<int> meldBreaks = null)
{
List<Vector2> positions = new List<Vector2>();
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.
Create: Core/Game/Screens/GinRummyGameplayScreen.cs
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();
}
}
}
Modify: Core/Game/Screens/MainMenuScreen.cs
// 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);
}
dotnet build
# For macOS/Linux
dotnet run --project Platforms/DesktopGL/CardsStarterKit.DesktopGL.csproj
# For Windows
dotnet run --project Platforms/WindowsDX/CardsStarterKit.WindowsDX.csproj
Basic Gameplay:
Meld Detection:
Knocking:
Gin:
NPC Behavior:
Add touch/mouse handling in the screen to let human players click cards to discard:
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)
}
}
}
Highlight cards that are part of melds:
// In AnimatedHandGameComponent or custom renderer:
foreach (var card in meldedCards)
{
// Draw green border or glow effect
}
To extend to full matches (first to 100 points):
// 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;
}
After a knock, allow opponents to add cards to knocker's melds:
public class LayOffPhase
{
public static List<TraditionalCard> FindLayOffCards(
List<TraditionalCard> hand,
List<Meld> opponentMelds)
{
// Check each hand card to see if it extends opponent's melds
// ...
}
}
Advanced NPC improvements:
Complex State Management:
Meld Detection Algorithm:
NPC Implementation:
Scoring Logic:
Multi-Player Support:
MeldDetector.FindOptimalMelds()TurnCompleteRule hand count trackingNextPlayer() is calledTo implement full Gin Rummy matches to 100 points:
1. Add Match Tracking:
public int MatchScore { get; set; }
public int RoundsWon { get; set; }
2. Add Match State:
public enum MatchPhase
{
InProgress,
Complete
}
3. Check After Each Round:
if (winner.MatchScore >= 100)
{
currentState = GinRummyGameState.MatchEnd;
ShowMatchResults();
}
else
{
PrepareNextRound();
}
4. Add Bonuses:
Want to explore other variants? Here are some links:
Oklahoma Gin:
Hollywood Gin:
Straight Gin:
Core/Game/GinRummy/Game/GinRummyGameState.csCore/Game/GinRummy/Game/Meld.csCore/Game/GinRummy/Game/MeldDetector.csCore/Game/GinRummy/Game/GinRummyCardGame.csCore/Game/GinRummy/Players/GinRummyPlayer.csCore/Game/GinRummy/Players/GinRummyNPCPlayer.csCore/Game/GinRummy/AI/GinRummyNPC.csCore/Game/GinRummy/Rules/KnockRule.csCore/Game/GinRummy/Rules/GinRule.csCore/Game/GinRummy/Rules/TurnCompleteRule.csCore/Game/GinRummy/UI/HandOrganizer.csCore/Game/Screens/GinRummyGameplayScreen.csCore/Game/Screens/MainMenuScreen.csCongratulations! You've built a complete Gin Rummy implementation with:
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:
Happy coding!