

Programming a 3D Tic Tac Toe game 


return to TicTacToe page.
see Delphi unit1
see Delphi unit2
download complete Delphi project
Introduction
This article describes a Delphi programming project of a 3 dimensional tictactoe game.
It is a two player version, however, for a single player it may be interesting to find a winning strategy.
A game state may be analysed: per field a calculation is made that predicts
the result of a move in that field (e.g. winning in 5 moves, losing in 7 moves).
This game is simple, as is the program. However, in the case of more complex games having
menus to choose from, single and dual player options or several game levels, the same structure may be used.
Also the analyses procedure is applicable to a wide variety of games.
Therefore this project is a general blueprint for board games.
The Delphi(7) project has one form and two units.
Form1 holds the game and three images used as buttons.
Unit1 handles events, paints the game and contains the procedures to control the game.
Unit2 has the data for the game, moves and the procedures for game analysis.
The game
Players put their marks ( "O" or "X") in a field, also called position in the program.
The breakdown into plane, row and column are only for the purpose of drawing and
the conversion of mousecoordinates to field number.
The game is drawn in paintbox1 and the next data supplies all information
type TPlane = record
left : word;
top : word;
square : word;
end;
const planes : array[0..2] of TPlane =
((left : 20; top : 450; square : 80),
(left : 280; top : 220; square : 70),
(left : 510; top : 20; square : 60));
fw = 9; //frame width
gameBGcolor = $feffff;
markcolor = $00b0ff;
chdef : array[1..2] of char = ('O','X');
playercolors : array[1..2] of dword = ($0000ff,$ff0000);
left, top are relative to the paintbox.
square is the length in pixels of a field.
The boardstate is recorded in array game
const maxmove = 27;
.....
var game : array[0..maxmove] of byte; //1=O 2=X
note: game[0] is not used.
The moves should be recorded in a list for the case of backward moving and also for analyses.
type TNode = record
pos : byte;
rating : byte;
treshold : byte;
end;
......
var node : array[1..maxmove] of TNode;
moveNr : byte;
pos is the move position (field).
rating and treshold are for analyses and will be explained later.
Conversions
Field numbers have to be converted to plane,row and column and vice versa.
type TPosTriple = record
column : byte;
row : byte;
plane : byte;
end;
........
function pos2triple(p : byte) : TPosTriple;
//convert position p to column,row,plane
begin
with result do
begin
dec(p);
column := p mod 3;
p := p div 3;
row := p mod 3;
plane := p div 3;
end;
end;
......
function triple2Pos(tr : TPosTriple) : byte;
//convert column,row,plane to position
begin
with tr do result := column + 3*row + 9*plane + 1;
end;
The position must also be converted to the field rectangle
function pos2Rect(p : byte) : Trect;
//calculate rectangle of a field [1..27]
var tr : Tpostriple;
begin
tr := pos2triple(p);
with tr do with planes[tr.plane] do
begin
result.left := left + square*column;
result.top := top + square*row;
result.Right := result.Left + square;
result.Bottom := result.Top + square;
inc(result.Left,fw);
dec(result.Bottom,fw);
end;
end;
note: fw is the width of the game edges. Purpse is to have a 3 dimension look.
The rectangle is limited to the area excluding the edges.
When moving the mouse pointer over the paintbox, the (x,y) mouse coordinates must be converted
to a field number. A field of 0 is supplied if no field or an allready occupied field is covered.
function xy2field(x,y : word) : byte;
//convert mousex,y to field
//no field = 0
var hit : boolean;
r : Trect;
i : byte;
begin
hit := false;
i := 0;
while (hit = false) and (i < maxmove) do
begin
inc(i);
r := pos2rect(i);
hit := (x > r.Left) and (x < r.Right) and
(y > r.top ) and (y < r.Bottom);
end;//while
if hit then result := i else result := 0;
end;
Painting the game
Painting includes the painting of the frame, of the player moves and marking/unmarking winning triples.
The frame is painted fw (framewidth) pixels wide to obtain a 3D effect.
Procedure multiline paints 3D lines.
procedure multiline(x1,y1,x2,y2: word; w : byte; colr : dword);
//paint w fold line (x1,y1) > (x2,y2) in paintbox1
var i : byte;
begin
with form1.PaintBox1.Canvas do
begin
pen.Width := 1;
pen.Color := colr;
for i := 0 to w1 do
begin
moveto(x1+i,y1i);
lineto(x2+i,y2i);
end;
end;
end;
The following procedures are mentioned but not listed here (refer to the source code of unit1)
procedure paintframe;// paints the edges of the game
procedure paintfield(..) paints a field with "O" or "X"
procedure paintgame;// paints all fields
procedure markwinner;// paints background color of winning fields
procedure unmarkwinner;// paints original background color of winning fields
Winning
type TWintriple = record
p1,p2,p3 : byte;
end;
A wintriple are three adjacent "O" or "X" marks.
p1, p2, p3 are the game fields.
A list winlist holds all triples that make a winning board state.
var winlist : array[1..50] of TWinTriple;
wincount : byte;
wincount holds the number of entries in winlist[ ]
The winlist is generated at creation time.
Originally I designed a quite complicated procedure to generate the winlist, but later a more simple one
struck my mind.
A winning triple may be defined by it's start and ending field.
The middle field simply is the average value of both.
By marking each field by a number, which is the same when they form a starting and ending field,
generation is simple.
procedure makewinlist;
const concode : array[1..maxmove] of byte =
(1,2,1,3,4,3,1,2,1,5,6,5,7,0,7,5,6,5,1,2,1,3,4,3,1,2,1);
var i,j : byte; //start..end field
begin
wincount := 0;
for i:= 1 to 25 do
for j := i+2 to 27 do
if concode[i] = concode[j] then
begin
inc(wincount);
with winlist[wincount] do
begin
p1 := i;
p2 := (i + j) shr 1;
p3 := j;
end;
end;
end;
The concode (connection code) number allows for the triples.
By having index j starting from i+2, double generation is avoided.
The procedure below returns true if the game holds a winning triple.
function checkwin(var tr : TwinTriple; player : byte) : boolean;
//check for win somewhere, return wintriple# tr
var i : byte;
begin
i := 0;
result := false;
repeat
inc(i);
with winlist[i] do
result := (game[p1] = player) and
(game[p2] = player) and
(game[p3] = player);
until result or (i = wincount);
if result then tr := winlist[i];
end;
Game control
A game has certain states or situations.
Responses to user input (keyboard,mouse) are situation dependent.
type TGameStatus = (gsInitializing,gsAnalysing,gsMoving,gsEnd);
TGameUpdate = (guMove,guWin,guAnalyseBtn,guNewBtn,guBackBtn);
.....
var gamestatus : TGamestatus;
Only in the state gsMoving, mouse and keyboard events are honoured.
guMove is the result of a mouseclick in an empty field.
Button clicks result in a gameUpdateMessage to Game Control. (guAnalyseBtn,guNewBtn,guBackBtn).
So, a model to handle these things is a tablelike structure where the events are the columns
and the game states are the rows.
At the intersection of columns and rows are handling code or procedure calls to accomplish a task.
This translates to nested case statements.
Similar structures arise in many other cases and this approach generates comprehensible code.
Note, that this game is very simple, but others may be not.
procedure GameControl(gum : TGameUpdate);
begin
case gamestatus of
gsMoving : case gum of
guMove : moveproc;
guBackBtn : backproc;
guAnalyseBtn : analyseproc;
guNewBtn : newproc;
end;//case
gsEnd : case gum of
guNewBtn : newproc;
guBackBtn : backproc;
end;//case
end;//case
end;
Nice is, that the origin of a guXXX message may be keyboard as well as mouse.
The GameControl procedure has several helpers, that take care of the necessary actions.
(moveproc, backproc, analyseproc).
Please refer to the source code of unit1 for the details.
Game analyses
Pressing the analyses button in the game, supplies the prediction per field for that move.
Typical is W3 for winning in three or L6 for losing in six moves.
During the game, moves are stored in the .pos field of the node (type TNode)
TNode = record
pos : byte;
rating : byte;
treshold : byte;
end;
.....
var node : array[1..maxNode] of TNode;
moveNr : byte;
moveNr is the last move done by a player.
pos is the field of the move (1..27)
The player number (1 for "O" , 2 for "X") is obtained from moveNr
player := 2  (moveNr and 1);
Analyses involves a large number of trial moves and registration of the results (wins).
These trial moves (not displayed) are also registered in array node[ ].
Two more variables are added to control this process
var baseNode : byte;
testnode : byte;
baseNode := moveNr + 1, so, the next free node, which holds the first trial move.
(btw : node is a math phrase from graph theory.
A node is typically a place where many roads originate or come together.
For "road", read "move".)
TestNode is the number of a trial move.
So, testNode runs from baseNode to 27 (maxmove).
Basically, the analyses procedure is of the so called "brute force" type.
This means, that all possible combinations of moves are generated.
However, without some tricks, analyses would require unacceptable long times.
Trial move counting
To test all possible sequences of moves, the analyser uses the following scheme:
(suppose an empty game, baseNode = 1)
move node 1 2 3 4 5 6 7 8 9
1 1
2 1 2
3 1 2 3
4 1 2 3 4
5 1 2 3 4 5
6 1 2 3 4 5 6
7 1 2 3 4 5 6 7 ........and "O" wins
Notes:
1. Remember, that nodes 1,3,5,7,.. are "O" and nodes 2,4 6,8,.... are "X"
2. If fields were occupied by former moves, they would be skipped.
For each new trial move the first empty field is selected.
Say that we start analyses at node 25 and no winning takes place.
Free fields are 2, 14 and 25.
After 3 trial moves:
node 25 26 27
pos 2 14 25
No more moves are possible. Field 25 removed, node 26 updates the move to 25, the next free field.
Then node 27 selects the only empty field 14
node 25 26 27
pos 2 25 14
Then field 14 is removed, node 26 cannot find a next empty field and therefore removes field 25.
Then node 25 selects the next free field 14, node 26 selects field 2, node 27 selects field 25
node 25 26 27
pos 14 2 25
The fields (stored in node[....].pos) form a counter.
The last "number" being
node 25 26 27
pos 25 14 2
When node 25 (baseNode) is unable to update it's move ( all moves done), the analyses procedure exits.
Rating
Somehow, we have to remember the "success" of a particular move.
The best, of course, is winning.
But also good is winning in 4 or 6 moves.
Losing in 7 moves is less worse then losing in 3 moves.
Nodes have a field called rating which is a number 1..100.
The meaning is
rating meaning
1 losing in 1 move
3 losing in 3 moves
5 losing in 5 moves
.......
50 neutral, no winning or losing
.......
96 winning in 4 moves
98 winning in 2 moves
100 winning move
A higher rating is always better.
If node 15 wins, it's rating is set to 100 and control is transferred to the previous node 14.
x := 100  node[testNode].rating;
if x > 50 then dec(x);
if x < 50 then inc(x);
dec(testNode);
if x > node[testnode].rating then node[testNode].rating := x;
So, if the rating of node 14 was 0, it now becomes 1 (losing in 1 move).
The rating of a node may only increase.
Each node tries to obtain the highest possible rating.
So, node 14 selects the next move and opens node 15.....and so on.
If no move is possible at the highest node, the rating is set to 50.
if a node is out of moves, it transfers control to the previous node.
This scheme works allready, move results can be predicted, but the process is extremely slow.
Time for some tricks for a dramatic speedup.
Speeding up analyses
1.
Say node n has rating 94 (win in 6 moves) and then node n+1 updates it's rating from 0 to 5.
This 5 would generate rating 94 for node n when node n+1 closes.
However, node n allready has this rating.
For node n+1 it is meaningless to search for any higher rating, say 90, which would present a rating
of 11 to node n: the rating 94 would not change.
Therefore, there are cases where the combination of ratings of two successive nodes results in closing of
a node and transferring control to the previous node.
This is called a shortcut.
2.
Instead of comparing ratings of two successive nodes, it is convenient to transfer the rating of node n to node n+1.
This new value is called the treshold
So from now, a node may compare it's rating to it's treshold.
If the rating is equal or exceeds the treshold, control is transferred to the previous node.
3.
Say, node 10 has rating 96 (winning in 4 moves = winning at node 14).
A better rating implies winning in a lower node. So, it is useless to search for any further moves beyond node 12.
As will be explained, the treshold takes care of that.
4.
The nodes form a tree, which is a graph without loops.
Note: in math, a graph is a figure with dots, which may be (or not be) connected by lines.
A graph is an abstract image.
Graphs are widely used in industrial planning, roadmaps, file systems, chemistry and computer games.
Actually in all circumstances where dependencies exist or changing states..
The number of combinations (moves, routes) is very high.
The more nodes are involved, the longer the analyses will take.
A good way to limit the depth is to search for any win just after opening a new node, so
first try every move for a win after actually doing the first trial move.
At a win, control is returned to the previous node, so the search depth is limited.
Analyses: Putting things together
moveNr is the last player move.
Pressing the analyse button wil
 set baseNode to moveNr + 1
 set testNode to moveNr
In the analyses process, there are 5 basic functions to fullfil:
 nextNode
 test for win
 firstmove
 previousnode
 nextmove
Below is a flowchart of the analyses process
Except for testWin, all blocks are functions that yield true or false.
There are three loops, implemented by while statements
 nextnode > testwin > firstmove > nextnode ...controlled by boolean variable Fnext
 previous node > no next move > previous node...controlled by Fprev
 nextmove > nextnode .....controlled by Ffirst
Below is the code
procedure analyse;
//analyse game
var Ffirst,Fnext,Fprev : boolean;
begin
if movenr = 27 then exit;
testNode := moveNr;
baseNode := moveNr + 1;
Ffirst := true;
while Ffirst do
begin
Fnext := true;
while Fnext do
begin
Fnext := false;
if nextNode then
begin
testwin;
Fnext := firstMove;
end;
end;
Fprev := true;
while Fprev do
if previousNode then
begin
if nextmove then Fprev := false;
end
else begin
Ffirst := false;
Fprev := false;
end;
end;//Ffirst
end;
NextNode
If there is no next node (in case of node 27) then exit false.
Else, increase testNode and initialize rating and treshold of the new node:
exit false if testnode = 27
testnode + 1
pos := 0 {no move yet}
baseNode > treshold := 100; rating := 1
not basenode >
x := 100  rating previous node
x > 50 > x + 1
x < 50 > x  1
testnode >= 26 > treshold = 50
testnode <> 25 > treshold := x
x := 100  treshold previous node
x > 50 > x + 1
x < 50 > x  1
rating := x
exit true
The treshold := 50 trick at node 26 will be explained later.
Test win
There is a difference of checking for the basenode and the higher nodes.
Searching and comparing the winlist[ ] with the game[ ] will stop for the higher nodes if a win is found.
For the basenode however, all fields on the board are checked,
winning triple numbers are displayed in array[1..27] scores.
This is because we want to know every field where to win.
Testwin sets the rating of node[testnode] to 100 in case of a win.
FirstMove
if treshold = 100 > treshold := 98 {no win found}
result := rating < treshold;
if result
find free field
set game[field]
set pos to player ( 1 for "O", 2 for "X"}
Previous node
This is the most complicated part
Variable prf (boolean) is true as long as the previous node is taken
which is the case when the updated rating equals or exceeds the treshold
result := false
prf := true
while prf
prf := false
delete move
testNode > basenode >
result := true {else result := false}
x := 100  rating
testnode  1
x > 50 > x  1
x < 50 > x + 1
testNode = baseNode > report rating
testNode > baseNode >
x > rating > rating := x
prf := rating >= treshold
end//while
Next move
After selecting the previous node, this function tries to do the next move of this node
result := false
delete move
find next free field > result := true
set game[free field]
please refer to the source code for details.
Each node tries to obtain the highest possible rating.
The previous node is selected if a higher rating does not change the rating of the previous node.
This avoids examination of millions of moves and is therefore the biggest time saving trick.
An exception is the baseNode: the rating is kept down to 1 so all moves are examined.
Picture below shows the algorithm for successive moves without wins.
Initially the treshold of a new node is set to 100, no win sets the treshold to 98
The nodes 26,27 treshold trick
In node 26,27 the treshold is standard set to 50
If there is no win at the last node (27) then rating = treshold (= 50) and the previous node is selected.
Below is pictured a winning situation at baseNode + 3, node +2 has done all it's moves without
improving it's rating, node +1 rating equals it's treshold so returns to the baseNode.
Search depth reduction
The algorithm automatically takes care of unnessary search depths.
Assume node 15 has found a rating of 94 (winning in 6 moves) long ago and now tries to improve this
At node 19, when no win condition is found, the treshold is lowered to 98.
The rating now equals the treshold so the previous node is selected.
Of course it makes no sense to search any further, as before a winning situation was found at node 21.
This concludes the description of 3D tictactoe.
The analyses algorithm is applicable to all board games, only the testwin procedure will be different.
In single player games, the algorithm may search for the best computer move.
Donations
In case you make commercial use of the analyses algorithm, do not forget a donation for my website.
Please contact me for details.

