Introduction
I'm writing to show and teach a personal undertaking, a game project called The Rule of Ready. This is a Japanese Mahjong game written from scratch in C#. I'll be documenting my engineering decisions and the implementations, bugs and all. I hope that seeing concepts in action will help developers improve.
What is This Game?
Mahjong is a Chinese four player tabletop game that has many variants around the world. I often describe it as a cross between gin rummy and poker, but played with tiles instead of cards. The tiles are shuffled and gathered into a wall in front of the players, much like a deck of cards in the middle of a table. Each player draws an initial hand of 13 tiles, and take turns drawing a tile and discarding one from their hand, until one player has a winning hand--4 sets of three tiles and a pair, typically. Players can also--under certain conditions--claim another player's discard to complete one of the sets of tiles, or to complete the hand.
Here's a screen shot from the Japanese Mahjong game Tenhou, from Osamuko's Mahjong Blog:
For more information on Japanese Mahjong, here are some sites to visit:
- A wiki for information about Japanese Mahjong
- A through PDF detailing both the rules and the Japanese terms for the game, in exhaustive detail, from a player only known as Barticle
- ReachMahjong.com, a community site for professional players of the game, with translated articles from professional Japanese players
- A Japanese Mahjong Flash game
Problem to Solve
Every project needs a place to start, so I'll start with a Mahjong tile. Japanese has a total of 136 tiles (Japanese terms are in itallics):
- There are 4 copies of each tile, and 34 different designs of tiles
- 27 of these are suit tiles (supai): 3 suits of 9 tiles each
- the 3 suits are Dots (pinzu) , Bamboos (sozu) , and Characters (manzu)
- the suits are numbered 1 to 9, and 1 and 9 are Terminals (rotohai)
- 7 are Honor Tiles (jiihai)
- 4 are Wind Tiles (kazehai), one of each direction: East (Ton), South (nan), West (sha), and North (Pe)
- 3 are Dragon Tiles (sangenpai): Green Dragon (hatsu), Red Dragon (chun), White Dragon (haku)
- The Terminals and Honors together are called Majors (yaochuhai)
- There a four Red Five suit tiles that can be substituted for the normal suit tiles (two 5-Dot, one 5-Bamboo, and one 5-Character). These are bonus (dora) tiles that add to a winning hand's score.
A Mahjong tile is going to be a piece of data that will be used in many aspects of the game. Some uses of this class will be determining what sets of 3 tiles are in a player's hand, are any tiles of a particular type in a player's hand, what tiles can help a player get closer to winning? All of these questions have a similar theme: "do the tiles in one or more collections match a condition?"
First Implementation
I started with a single class for a mahjong tile, with an enum
for the type and classification of the tile.
First, an enum
for the basic tile type. I'm using the convention that 0
is an error value.
public enum MahjongTileType
{
UnknownTile = 0,
SuitTile,
HonorTile
}
For Suit tiles, I need a suit, a number, and whether it's a Red tile or not. For Honor Tiles, I just need what dragon or wind tile to make. All of this categorization has a better implementation in C#, as a flags enum
.
[Flags]
public enum MahjongTileType
{
UnknownTile = 0,
Bambooo = 0x1,
Character = 0x2,
Dot = 0x4,
GreenDragon = 0x8,
RedDragon = 0x10,
WhiteDragon = 0x20,
EastWind = 0x40,
SouthWind = 0x80,
WestWind = 0x100,
NorthWind = 0x200,
DragonTile = GreenDragon | RedDragon | WhiteDragon,
WindTile = EastWind | SouthWind | WestWind | NorthWind,
HonorTile = DragonTile | WindTile,
SuitTile = Bambooo | Character | Dot
}
public class MahjongTile
{
public MahjongTileType TileType { get; private set; }
public int SuitNumber { get; private set; }
public bool IsRedTile { get; private set; }
public MahjongTile(MahjongTileType tileType)
{
if (tileType != MahjongTileType.HonorTile)
throw new ArgumentException("This constructor overload is only for honor tiles",
"tileType");
this.TileType = tileType;
this.SuitNumber = 0;
this.IsRedTile = false;
}
public MahjongTile(MahjongTileType tileType, int suitNumber, bool isRed)
{
if (tileType != MahjongTileType.SuitTile)
throw new ArgumentException("This constructor overload is only for suit tiles",
"tileType");
if (suitNumber < 1 || suitNumber > 9)
throw new ArgumentException("Suit tiles have values from 1 and 9",
"suitNumber");
this.TileType = tileType;
this.SuitNumber = suitNumber;
this.IsRedTile = isRed;
}
}
The argument checks on the constructors are a code smell: an incorrectly created tile won't be caught until runtime. How about using MahjongTile
objects? Let's see how code for answering some of the questions would look like:
List<mahjongtile> hand = new List<mahjongtile>()
{
};
int hasEastWind = hand.Where(mt => mt.TileType == MahjongTileType.EastWind).Count();
bool hasHonorTiles = hand.Where(mt => mt.TileType == MahjongTileType.HonorTile).Any();
While this works well enough for simple questions, complicated ones will require complex Where
filter functions. For example, one of the central questions to the game is "how many tiles do I need to win?" along with "what tiles do I need to win?" These questions are not trivial to answer. I could live with this, but it's concerning. Combined with the constructor code smell, I should reconsider my design. (I had some help from the CodeProject community as well.)
Second Implementation
One item of note is that a Mahjong tile doesn't change after it's been created. This means that if I define value equality for a Mahjong tile, I can encapsulate the details for equating tiles within the class without needing public
properties to do so. Also, sorting the tiles in a hand seems reasonable, at least for the user interface, which means tiles should be able to be compared to one another.
I had a few challenges in getting the code to be readable, rather than merely correct. Observations are below:
public enum MahjongSuitType
{
Bamboo = 1,
Character,
Dot
}
public enum MahjongSuitNumber
{
One = 1,
Two,
Three,
Four,
Five,
Six,
Seven,
Eight,
Nine
}
public enum MahjongHonorType
{
EastWind = 1,
SouthWind,
WestWind,
NorthWind,
RedDragon,
WhiteDragon,
GreenDragon
}
public abstract class MahjongTile : IEquatable<mahjongtile>, IComparable<mahjongtile>
{
#region Public Methods
public override bool Equals(object obj)
{
return this.EqualToImpl(obj as MahjongTile);
}
public override int GetHashCode()
{
return this.GetHashCodeImpl();
}
#region IEquatable Implementation
public bool Equals(MahjongTile other)
{
return this.EqualToImpl(other);
}
#endregion
#region IComparable Implementation
public int CompareTo(MahjongTile other)
{
return this.CompareToImpl(other);
}
#endregion
#region Operator overloads
public static bool operator ==(MahjongTile left, MahjongTile right)
{
bool leftIsNull = Object.ReferenceEquals(left, null);
bool rightIsNull = Object.ReferenceEquals(right, null);
if (leftIsNull && rightIsNull)
return true;
else if (leftIsNull || rightIsNull)
return false;
else
return left.EqualToImpl(right);
}
public static bool operator !=(MahjongTile left, MahjongTile right)
{
return !(left == right);
}
public static bool operator <(MahjongTile left, MahjongTile right)
{
return left.CompareTo(right) < 0;
}
public static bool operator >(MahjongTile left, MahjongTile right)
{
return left.CompareTo(right) > 0;
}
public static bool operator <=(MahjongTile left, MahjongTile right)
{
return left.CompareTo(right) <= 0;
}
public static bool operator >=(MahjongTile left, MahjongTile right)
{
return left.CompareTo(right) >= 0;
}
#endregion
#endregion
#region Protected Abstract Members
protected abstract int CompareToImpl(MahjongTile other);
protected abstract bool EqualToImpl(MahjongTile other);
protected abstract int GetHashCodeImpl();
#endregion
}
public class MahjongSuitTile : MahjongTile
{
#region Public Properties (read only)
public MahjongSuitType SuitType { get; private set; }
public MahjongSuitNumber SuitNumber { get; private set; }
public bool IsRedBonus { get; private set; }
#endregion
#region Constructor
public MahjongSuitTile(MahjongSuitType suitType, MahjongSuitNumber suitNumber,
bool isRedBonus = false)
{
if (!Enum.IsDefined(typeof(MahjongSuitType), suitType))
throw new ArgumentException(
string.Format("'{0}' is not a valid suit type",
suitType), "suitType");
if (!Enum.IsDefined(typeof(MahjongSuitNumber), suitNumber))
throw new ArgumentException(
string.Format("'{0}' is not a valid suit number",
suitNumber), "suitNumber");
this.SuitType = suitType;
this.SuitNumber = suitNumber;
this.IsRedBonus = isRedBonus;
}
public MahjongSuitTile(MahjongSuitType suitType, int suitNumber, bool isRedBonus = false)
: this(suitType, (MahjongSuitNumber)suitNumber, isRedBonus) { }
#endregion
#region Protected Override Members
protected override bool EqualToImpl(MahjongTile other)
{
if (Object.ReferenceEquals(other, null))
return false;
if (Object.ReferenceEquals(other, this))
return true;
MahjongSuitTile otherSuitTile = other as MahjongSuitTile;
if (Object.ReferenceEquals(otherSuitTile, null))
return false;
return (this.SuitType == otherSuitTile.SuitType) &&
(this.SuitNumber == otherSuitTile.SuitNumber);
}
protected override int GetHashCodeImpl()
{
return this.SuitType.GetHashCode() ^ (this.SuitNumber.GetHashCode() << 4);
}
protected override int CompareToImpl(MahjongTile other)
{
if (Object.ReferenceEquals(other, null))
return 1;
MahjongSuitTile otherAsSuit = other as MahjongSuitTile;
if (Object.ReferenceEquals(otherAsSuit, null))
return -1;
else
{
int suitCompare = this.SuitType - otherAsSuit.SuitType;
if (suitCompare != 0)
return suitCompare;
else return this.SuitNumber - otherAsSuit.SuitNumber;
}
}
#endregion
}
public class MahjongHonorTile : MahjongTile
{
#region Public Properties (read only)
public MahjongHonorType HonorType { get; private set; }
#endregion
#region Constructor
public MahjongHonorTile(MahjongHonorType honorType)
{
if (!Enum.IsDefined(typeof(MahjongHonorType), honorType))
throw new ArgumentException(
string.Format("'{0}' is not a valid honor type",
honorType), "honorType");
this.HonorType = honorType;
}
#endregion
#region Protected Override Members
protected override bool EqualToImpl(MahjongTile other)
{
if (Object.ReferenceEquals(other, null))
return false;
if (Object.ReferenceEquals(other, this))
return true;
MahjongHonorTile otherHonorTile = other as MahjongHonorTile;
if (Object.ReferenceEquals(otherHonorTile, null))
return false;
return this.HonorType == otherHonorTile.HonorType;
}
protected override int GetHashCodeImpl()
{
return this.HonorType.GetHashCode();
}
protected override int CompareToImpl(MahjongTile other)
{
if (Object.ReferenceEquals(other, null))
return 1;
MahjongHonorTile otherAsHonor = other as MahjongHonorTile;
if (object.ReferenceEquals(otherAsHonor, null))
return 1;
else
return this.HonorType - otherAsHonor.HonorType;
}
#endregion
}
Here are my observations:
- Working through equality for disjoint subclasses was an interesting journey. I needed both a solution that would have each subclass determine its own definition for equating, comparing, and generating a hash code and a mechanism for the base class to call the correct method for each possible situation. The protected
abstract
methods leverage C#'s built-in mechanism for doing this. - What was really surprising for me is that I didn't have to specifically implement the interfaces in the subclasses, even though they contained the implementation.
- Since I was defining operators for
MahjongTile
objects, I was careful to not use any operators to compare them. It's arguably paranoid to use Object.ReferenceEquals
when comparing to null
, but I felt it was better to be safe. - As I was working out the details of defining equality, unit-testing both helped me find typos and ensure correct behaviour as a re-factored.
- The
enum
s determine both the range of acceptable values and the ordering of tiles. This includes the overloaded constructor for suit tiles that takes in an integer for the suit number.
Source
The source code for this project is at ruleofready.codeplex.com. I have unit tests there as well.
Next
Next time, I'll be creating the engine that will make use of the tiles.
History
- Sep. 25, 2013: Created article
- Sep. 28, 2013: Added in second implementation and description of Mahjong