Introduction
This article attempts to describe a way of creating a set of classes that are generic enough to allow several kinds of board games to be easily implemented. Only the actual game logic (the rules of the game) should have to change between implementations.
Updated
This article have been updated with two things:
- General cleanup of the API, including a generic
System.Windows.Forms.Form
implementation that further reduces implementation time - An implementation of Connect Four
Both of these additions are discussed further in this article.
Requirements
When starting this project, I settled for a set of requirements that the finished code needed to fulfill:
- The implementation must be generic enough to allow an implementation of Checkers and an implementation of Chess to be created without modifying the board game implementation.
- Visual representation of the game must be in 3D using DirectX.
- When implementing a new board game, the person implementing does not need to have any knowledge of 3D mathematics or Direct3D.
- The implementation should not require a "game loop," as this is often an unfamiliar concept to persons not used to implementing games.
- A
System.Window.Forms.Panel
component should be used to render the game so that it can be placed onto a form like any other visual component.
Using the Code
The Visual Studio solution is made up of two projects:
- A class library containing the generic code that should be applicable to any board game (I will refer to this as "the Framework")
- A Windows application demonstrating the board game library using an implementation of Checkers
The game logic (in this example, the Checkers implementation) must implement an interface called IBoardGameLogic
, which is defined as:
using System;
using System.Collections.Generic;
namespace Bornander.Games.BoardGame
{
public interface IBoardGameLogic
{
int Rows
{
get;
}
int Columns
{
get;
}
int this[int row, int column]
{
get;
set;
}
int this[Square square]
{
get;
set;
}
List<move /> GetAvailableMoves();
void Initialize();
bool IsGameOver(out string gameOverStatement);
List<move> Move(Square square, Square allowedSquare);
}
}
By exposing these few methods, the Framework can control the game flow and make sure that the rules of the game are followed. There is one thing missing, though: there is no way for the Framework to figure out what to display. It can deduce a state of each square, but it has no information about what should be rendered to the screen to visualize that state. This is solved by providing the Framework with an instance of another interface, called IBordGameModelRepository
:
using System;
using Bornander.Games.Direct3D;
namespace Bornander.Games.BoardGame
{
public interface IBoardGameModelRepository
{
void Initialize(Microsoft.DirectX.Direct3D.Device device);
Model GetBoardSquareModel(Square square);
Model GetBoardPieceModel(int pieceId);
}
}
By keeping the interfaces IBoardGameLogic
and IBoardGameModelRepository
separate, we allow the game logic to be completely decoupled from the visual representation. This is important because we might want to port this game to a Windows Mobile device, for example, where a 2D representation is preferred over a 3D one.
Now that the Framework has access to all of the information it needs for rendering the state of the game, it is time to consider the render implementation. Almost all game rendering is handled by VisualBoard
. This class queries IBoardGameLogic
and uses the information returned, together with the Model
s returned by IBoardGameModelRepository
, to render both the board and the pieces.
There is one element that is not rendered by VisualBoard
and that is the currently selected piece, i.e., the piece the user is currently moving around. Another class called GamePanel
, which extends System.Windows.Forms.Panel
, handles input as well as selecting and moving pieces around on the board. This type of implementation might seem to lower the inner cohesion of the GamePanel
class, but I decided to do it this way because I want VisualBoard
to render the state of the board game. That state does not know anything about a piece currently being moved.
Classes Overview
Board Game Class Library
These are the classes in the class library:
GamePanel
extends System.Windows.Forms.Panel
and handles user input, DirectX setup and some rendering VisualBoard
is responsible for rendering IBoardGameLogic
IBoardGameLogic
, an interface, represents the rules of a board game IBoardGameModelRepository
, an interface, represents a repository of 3D models used to render IBoardGameLogic
Move
represents all possible moves originating from a specific Square
Square
represents a board square by holding row and column information Camera
represents a camera that is used when rendering in 3D Model
groups a Mesh
, a Material
and a position and orientation together for convenience
Checkers Application
These are the classes in the Checkers application:
CheckersModelRepository
implements IBoardGameModelRepository
and is responsible for returning models related to rendering the Checkers board CheckersLogic
is the Checkers game logic implementation; it implements CheckersLogic
Rendering the Game State
Rendering the state of the game is pretty straightforward: loop over all board squares, render the square and then render any piece occupying that square. Simple. However, we also need to indicate to the user which moves are valid. This is done by highlighting the board square under the mouse if it is valid to move from that square (or to that square when "holding" a piece).
It is important to mention that the Framework makes the assumption that the squares are 1 unit wide and 1 unit deep (height is up to the game developer to decide). This must be taken into account when creating the meshes for the game. To help out with this, the Model
class holds two materials: one "normal" material and one "highlighted" material.
By checking whether the model is in state "Selected
," it sets its material to either normal or highlighted just prior to rendering, like this:
class Model
{
private Material[] material;
...
public void Render(Device device)
{
device.Transform.World = this.World;
device.Material = material[selected ? 1 : 0];
mesh.DrawSubset(0);
}
}
VisualBoard
simply sets the selected state for each board square model before rendering it in its Render
method:
public class VisualBoard
{
...
public void Render(Device device)
{
for (int row = 0; row < gameLogic.Rows; ++row)
{
for (int column = 0; column < gameLogic.Columns; ++column)
{
Square currentSquare = new Square(row, column);
Model boardSquare =
boardGameModelRepository.GetBoardSquareModel
(currentSquare);
boardSquare.Position =
new Vector3((float)column, 0.0f, (float)row);
boardSquare.Selected = currentSquare.Equals(selectedSquare);
boardSquare.Render(device);
if (!currentPieceOrigin.Equals(currentSquare))
{
Model pieceModel =
boardGameModelRepository.GetBoardPieceModel
(gameLogic[currentSquare]);
if (pieceModel != null)
{
pieceModel.Position = new Vector3((float)column,
0.0f, (float)row);
pieceModel.Render(device);
}
}
}
}
}
}
Figuring out which square is actually selected is a matter of finding which square is "under" the mouse. In 2D, this is a really simple operation, but it gets slightly more complicated in 3D. We need to grab the mouse coordinates on the screen and, using the Projection and View matrices, un-project the screen coordinates to 3D coordinates. Then, when we have our mouse position as a 3D position, we can cast a ray towards all our board square Model
s to see if we get an intersection. The code for this can be difficult to understand for someone not used to 3D mathematics. This is why the Framework must take care of it for us so that we (the guy or gal implementing a board game) don't have to worry about such things. A function handles all this in the VisualBoard
class:
public bool GetMouseOverBlockModel(Device device, int mouseX, int mouseY,
out Square square, List<square /> highlightIfHit)
{
selectedSquare = Square.Negative;
square = new Square();
bool foundMatch = false;
float closestMatch = int.MaxValue;
for (int row = 0; row < gameLogic.Rows; ++row)
{
for (int column = 0; column < gameLogic.Columns; ++column)
{
Square currentSquare = new Square(row, column);
Model boardSquare =
boardGameModelRepository.GetBoardSquareModel(currentSquare);
boardSquare.Position =
new Vector3((float)column, 0.0f, (float)row);
Vector3 near = new Vector3(mouseX, mouseY, 0.0f);
Vector3 far = new Vector3(mouseX, mouseY, 1.0f);
near.Unproject(device.Viewport, device.Transform.Projection,
device.Transform.View, boardSquare.World);
far.Unproject(device.Viewport, device.Transform.Projection,
device.Transform.View, boardSquare.World);
far.Subtract(near);
IntersectInformation closestIntersection;
if (boardSquare.Mesh.Intersect(near, far,
out closestIntersection)
&& closestIntersection.Dist < closestMatch)
{
closestMatch = closestIntersection.Dist;
square = new Square(row, column);
if (highlightIfHit != null)
{
foreach (Square highlightSquare in highlightIfHit)
{
if (highlightSquare.Equals(square))
{
selectedSquare = new Square(row, column);
}
}
}
foundMatch = true;
}
}
}
return foundMatch;
}
Handling Input
Obviously, there has to be a way of moving pieces around on the board. I decided that the most intuitive way of doing this is to grab and drag pieces using the left mouse button. That is really all the input a simple board game needs, but I also wanted to allow the user to view the board from different angles. This means positioning the camera at different places. I decided that using the right mouse button and dragging should be used for this, and that scrolling the mouse wheel should zoom in and out. This means I have to handle MouseDown
, MouseUp
, MouseMove
and MouseWheel
in the GamePanel
class:
public void HandleMouseWheel(object sender, MouseEventArgs e)
{
cameraDistanceFactor = Math.Max(0.0f, cameraDistanceFactor +
Math.Sign(e.Delta) / 5.0f);
SetCameraPosition();
Render();
}
private void GamePanel_MouseMove(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Right)
{
cameraAngle += (e.X - previousPoint.X) / 100.0f;
cameraElevation = Math.Max(0, cameraElevation +
(e.Y - previousPoint.Y) / 10.0f);
SetCameraPosition();
previousPoint = e.Location;
}
Square square;
if (e.Button == MouseButtons.Left)
{
if (ponderedMove != null)
{
if (board.GetMouseOverBlockModel
(device, e.X, e.Y, out square, ponderedMove.Destinations))
{
selectedPiecePosition.X = square.Column;
selectedPiecePosition.Z = square.Row;
}
}
}
else
{
board.GetMouseOverBlockModel(device, e.X, e.Y, out square,
GamePanel.GetSquaresFromMoves(availableMoves));
}
Render();
}
private void GamePanel_MouseDown(object sender, MouseEventArgs e)
{
previousPoint = e.Location;
if (e.Button == MouseButtons.Left)
{
ponderedMove = null;
Square square;
if (board.GetMouseOverBlockModel(device, e.X, e.Y, out square, null))
{
foreach (Move move in availableMoves)
{
if (square.Equals(move.Origin))
{
selectedPieceModel = board.PickUpPiece(square);
selectedPiecePosition =
new Vector3(square.Column, 1.0f, square.Row);
ponderedMove = move;
break;
}
}
}
}
Render();
}
private void GamePanel_MouseUp(object sender, MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
Square square;
if (board.GetMouseOverBlockModel(device, e.X, e.Y, out square, null))
{
if (ponderedMove != null)
{
foreach (Square allowedSquare in ponderedMove.Destinations)
{
if (square.Equals(allowedSquare))
{
availableMoves = gameLogic.Move
(ponderedMove.Origin, allowedSquare);
break;
}
}
}
}
board.DropPiece();
selectedPieceModel = null;
Render();
CheckForGameOver();
}
}
Piece Control
The mouse methods check if they're called as a result of a left mouse button press. If they are, they use the VisualBoard.GetMouseOverBlockModel
method to determine whether the event occurred when the cursor was over a specific square. This is then used to figure out if the user is allowed to pick up a piece from or drop a piece onto the current square. Also, VisualBoard.GetMouseOverBlockModel
internally handles square highlighting automatically.
Camera Control
If the right mouse button is down when dragging the mouse, I can figure out the delta between two updates and use that information to update two members. A third member is updated when the mouse wheel is scrolled:
private float cameraAngle = -((float)Math.PI / 2.0f);
private float cameraElevation = 7.0f;
private float cameraDistanceFactor = 1.5f;
Another method in GamePanel
then uses that information to calculate a position for the camera. This position is constrained to a circle around the board (the radius is adjusted when zooming) and the camera is also constrained along the Y-axis to never go below zero:
private void SetCameraPosition()
{
float cameraX = gameLogic.Columns /
2.0f + (cameraDistanceFactor * gameLogic.Columns *
(float)Math.Cos(cameraAngle));
float cameraZ = gameLogic.Rows /
2.0f + (cameraDistanceFactor * gameLogic.Rows *
(float)Math.Sin(cameraAngle));
camera.Position = new Vector3(
cameraX, cameraElevation, cameraZ);
}
Creating Models
The class CheckersModelRepository
is used to create all of the models used to render the Checkers
game. It implements IBoardGameModelRepository
so that the Framework has a generic way of accessing the models using the IBoardGameLogic
data.
class CheckersModelRepository
{
...
public void Initialize(Microsoft.DirectX.Direct3D.Device device)
{
Mesh blockMesh = Mesh.Box(device, 1.0f, 0.5f, 1.0f).Clone
(MeshFlags.Managed, VertexFormats.PositionNormal |
VertexFormats.Specular, device);
Material redMaterial = new Material();
redMaterial.Ambient = Color.Red;
redMaterial.Diffuse = Color.Red;
Material highlightedRedMaterial = new Material();
highlightedRedMaterial.Ambient = Color.LightSalmon;
highlightedRedMaterial.Diffuse = Color.LightSalmon;
Material squareBlackMaterial = new Material();
Color squareBlack = Color.FromArgb(0xFF, 0x30, 0x30, 0x30);
squareBlackMaterial.Ambient = squareBlack;
squareBlackMaterial.Diffuse = squareBlack;
Material blackMaterial = new Material();
blackMaterial.Ambient = Color.Black;
blackMaterial.Diffuse = Color.Black;
Material highlightedBlackMaterial = new Material();
highlightedBlackMaterial.Ambient = Color.DarkGray;
highlightedBlackMaterial.Diffuse = Color.DarkGray;
Material[] reds = new Material[]
{ redMaterial, highlightedRedMaterial };
Material[] blacks = new Material[]
{ blackMaterial, highlightedBlackMaterial };
blackSquare = new Model(blockMesh, new Material[]
{ squareBlackMaterial, highlightedBlackMaterial });
redSquare = new Model(blockMesh, reds);
blackSquare.PositionOffset = new Vector3(0.0f, -0.25f, 0.0f);
redSquare.PositionOffset = new Vector3(0.0f, -0.25f, 0.0f);
Mesh pieceMesh = Mesh.Cylinder(device, 0.4f, 0.4f, 0.2f, 32, 1).Clone
(MeshFlags.Managed, VertexFormats.PositionNormal |
VertexFormats.Specular, device);
Mesh kingPieceMesh =
Mesh.Cylinder(device, 0.4f, 0.2f, 0.6f, 32, 1).Clone
(MeshFlags.Managed, VertexFormats.PositionNormal |
VertexFormats.Specular, device);
redPiece = new Model(pieceMesh, new Material[]
{ redMaterial, redMaterial });
blackPiece = new Model(pieceMesh, new Material[]
{ blackMaterial, blackMaterial });
redKingPiece = new Model(kingPieceMesh, new Material[]
{ redMaterial, redMaterial });
blackKingPiece = new Model(kingPieceMesh, new Material[]
{ blackMaterial, blackMaterial });
redPiece.PositionOffset = new Vector3(0.0f, 0.1f, 0.0f);
redKingPiece.PositionOffset = new Vector3(0.0f, 0.3f, 0.0f);
blackPiece.PositionOffset = new Vector3(0.0f, 0.1f, 0.0f);
blackKingPiece.PositionOffset = new Vector3(0.0f, 0.3f, 0.0f);
Quaternion rotation = Quaternion.RotationAxis
(new Vector3(1.0f, 0.0f, 0.0f), (float)Math.PI / 2.0f);
redPiece.Orientation = rotation;
blackPiece.Orientation = rotation;
redKingPiece.Orientation = rotation;
blackKingPiece.Orientation = rotation;
}
}
Rotation and Translation
I will not explain in detail how 3D math is used to rotate and translate objects in 3D space, but I will explain what is done in the example implementation. Code statements like redPiece.PositionOffset = new Vector3(0.0f, 0.1f, 0.0f);
are used to make sure that the "origin" of the model is offset by 0.1
along the Y-axis. This is done because Mesh::Cylinder
creates a cylinder with the origin in the center of the cylinder and we need it to be at the edge of the cylinder for it to be placed correctly on the board. Also, we have to rotate in 90 degrees (PI / 2 radians) around the X-axis because it is created extending along the Z-axis and we want it to extend along the Y-axis. This is why this code is used:
...
Quaternion rotation = Quaternion.RotationAxis(new Vector3(1.0f, 0.0f, 0.0f),
(float)Math.PI / 2.0f);
redPiece.Orientation = rotation;
...
Vertex Format
It is also important to have a Mesh
that has a vertex format that suits our purposes. A vertex in a 3D model can contain different information depending on how it is going to be used. At the very least, Position data must be included. However, if the model is to have a color, Diffuse data must also be included. In the Framework, a directional light is used to shade the scene to look nicer. Because of this, Normal data must also be included.
The Mesh
returned from the static
methods on Mesh
used to create different geometrical meshes (such as boxes and cylinders) does not return a Mesh
with the vertex format we want. To fix this, we clone the mesh and pass the desired vertex format when cloning:
Mesh blockMesh = Mesh.Box(device, 1.0f, 0.5f, 1.0f).Clone(MeshFlags.Managed,
VertexFormats.PositionNormal | VertexFormats.Specular, device);
Code Clean-up
(This section added in version 2.)
When implementing a second game, Connect Four, I realized that the entire form setup could be reused and decided to provide an implementation in the API class library for this. This lessened the actual amount of code required when implementing a game. The form creation and startup code for the Checkers
game is then reduced to this:
is
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new GameForm(
new CheckersLogic(),
new CheckersModelRepository(),
"Checkers",
"Checkers, a most excellent game!"));
}
This creates a new GameForm
object and passes IBoardGameLogic
, IBoardGameModelRepository
, the window title and the About text in the constructor. To be able to make the GameForm
class able to display which players turn, it is that I had to add another method to IBoardGameLogic
. I didn't want GameForm
to have to poll the game logic for this information and decided to use a callback implemented with delegates. This required a new and additional method on the interface
, as well as a delegate
:
public delegate void NextPlayerHandler(string playerIdentifier);
public interface IBoardGameLogic
{
...
void SetNextPlayerHandler(NextPlayerHandler nextPlayerHandler);
}
Now the form implementation can add one of its methods as NextPlayerHandler
to the game logic. It is up to the game logic to indicate when the player changes. Super simple!
Connect Four Implementation
(This section added in version 2)
In order to show how easy it would be to implement another game, I decided to write a Connect Four game using this API. I chose Connect Four because it is fundamentally different from Checkers
in some ways. I wanted to show that, regardless of these differences, it would be not only possible, but actually quite simple to implement.
The biggest difference is that in Connect Four, you do not start out with all the pieces on the board. Rather, you pick them from a pile and then place them on the board. By using a "logical" board that is larger that the board actually used, I created two areas from which an endless supply of pieces could be picked. By having IBoardGameModelRepository
return null
for the squares that weren't part of either the actual board or the "pile" areas, GamePanel
could ignore rendering of these squares.
Shown above is the Connect Four implementation using very awesome-looking teapots as pieces. The actual game logic implementation for the Connect Four game is quite simple and took about the time it takes to take the train from London to Brighton and back again. :)
Final Result
So, how does the final implementation live up to the requirements I set out to fulfill? Sadly, I have to say that I failed to comply fully with requirement #3, that being that when implementing a new board game, the person implementing does not need to have any knowledge of 3D mathematics or Direct3D. The need for 3D knowledge can be seen in the CheckersModelRepository
class where meshes are created, translated and rotated (rotated using scary quaternions, no less!). This is stuff that requires at least a beginner's knowledge of 3D mathematics.
This is also quite far from being a complete Framework, as it does not currently support a computer player. Furthermore, since I decided that I should not require any game loop, there is no smooth animation when moving pieces around. Other than that, I think it turned out quite well. It took me less than an hour to implement the Checkers
game once the Framework was fully implemented and I think that indicates that it is easy to implement board games using this Framework.
I appreciate any comments, both on the code and on this article.
History
- 2007/11/11: First version
- 2007/11/25: Second version, added Connect Four and cleaned up the implementation