# 3D Tic Tac Toe/Connect Four game with AI

I came up with this during English lessons many years ago to kill some time, and just decided to try code it. It's basically a 3D version of Connect Four/Noughts and Crosses/Tic Tac Toe, but the grid can also flip itself, which means the people good at spacial awareness have a chance against people good at planning ahead.

Here's the general concept of how it works: it supposed to be 4x4x4 (coded to allow other sizes, though be warned an odd number gives player 1 a huge advantage), and you can get a point from making any complete row in any direction. The game ends once all available spaces are taken, and obviously the player with the most points wins.

A quick example with paint showing where the points are mid-game: Anyway, I'm not sure how to do user interfaces yet, so it's literally just a printed output and a raw_input to choose where to go (works with coordinates or the cell ID, eg. first cell is (1,1,1) or 0), which admittedly doesn't make it very easy to play. Ideally it should be colour coded and you just click where to go.

You can run the game by using Connect3D().play(), though it'd be safer to do something like c3d = Connect3D, then c3d.play(), so you can resume a game if you quit it (for now, quit by hitting escape or cancel and it'll throw an error, I'll sort this out later).

The grid_data is stored as a 1D list where a bit of maths is used to calculate how to go each direction. To make sure it doesn't loop to the other side, the edge points of each direction are stored, so if any edge point is met, it knows it's reached the end of the line, and will look in the opposite direction. I know I haven't explained that well, but for example. with the above picture, for the direction up, the edges would be 0-15.

I'm aware a 3D list would have been much easier, but I quite liked the idea of storing it in a 1D list so that it'd be easy to save and recreate the grid with a single string.

Using the above picture, here's how the game would look in its current state. I'd missed one the line going from 2,2,1 to 2,2,4 in the picture, which is why the scores don't match.

I'm trying to get better at proper formatting and comments and stuff, so I'm mostly looking for feedback on what I'm still not doing so well. That said, any suggestions on bits I could have coded better or optimised are also welcome.

Player X: ////  Player O: /////
________________
/ X /   /   / O /|
/___/___/___/___/ |
/ O / X /   / X /  |
/___/___/___/___/   |
/   / O / X / O /    |
/___/___/___/___/     |
/   /   /   / O /      |
/___/___/___/___/       |
|       |________|_______|
|       /   /   /|O /   /|
|      /___/___/_|_/___/ |
|     / X / X / O|/ X /  |
|    /___/___/___|___/   |
|   /   / O / O /|  /    |
|  /___/___/___/_|_/     |
| /   /   / O /  |/      |
|/___/___/___/___|       |
|       |________|_______|
|       / X / O /|  /   /|
|      /___/___/_|_/___/ |
|     / X / X / O|/   /  |
|    /___/___/___|___/   |
|   /   / O / X /|  /    |
|  /___/___/___/_|_/     |
| /   /   /   / X|/      |
|/___/___/___/___|       |
|       |________|_______|
|       / O /   /|  /   /
|      /___/___/_|_/___/
|     / X / X / X|/ X /
|    /___/___/___|___/
|   / O / O /   /|  /
|  /___/___/___/_|_/
| / O /   /   / X|/
|/___/___/___/___|
Player O's turn...


Which can be continued by using:

Connect3D(_raw_data='X  OOX X OXO   O  O XXOX OO   O XO  XXO  OX    XO   XXXXOO  O  X.1').play()


The code is here (change both values at the bottom to false to see the computer players face off):

import itertools
import operator
import random
import time
from collections import defaultdict
class Connect3DError(Exception):
pass

class Connect3D(object):
"""Class to store the Connect3D game data.
The data is stored in a 1D list, but is converted to a 3D representation for the user.
"""
player_symbols = 'XO'
grid_size_recommended = 4

def __init__(self, grid_size=grid_size_recommended):
"""Set up the grid and which player goes first.

Parameters:
grid_size (int): How long each side of the grid should be.
The game works best with even numbers, 4 is recommended.
"""

try:
self.current_player
except AttributeError:
self.current_player = random.randint(0, 1)

self._display_score = False

try:
self.grid_size = int(grid_size)
except TypeError:
raise TypeError('grid_size must be an integer')
self.grid_data = ['' for i in range(pow(grid_size, 3))]

self.grid_size_squared = pow(self.grid_size, 2)

#Calculate the edge numbers for each direction
self.direction_edges = {}
self.direction_edges['U'] = range(self.grid_size_squared)
self.direction_edges['D'] = range(self.grid_size_squared*(self.grid_size-1), self.grid_size_squared*self.grid_size)
self.direction_edges['R'] = [i*self.grid_size+self.grid_size-1 for i in range(self.grid_size_squared)]
self.direction_edges['L'] = [i*self.grid_size for i in range(self.grid_size_squared)]
self.direction_edges['F'] = [i*self.grid_size_squared+j+self.grid_size_squared-self.grid_size for i in range(self.grid_size) for j in range(self.grid_size)]
self.direction_edges['B'] = [i*self.grid_size_squared+j for i in range(self.grid_size) for j in range(self.grid_size)]
self.direction_edges[' '] = []

#Calculate the addition needed to move in each direction
self.direction_maths = {}
self.direction_maths['D'] = self.grid_size_squared
self.direction_maths['R'] = 1
self.direction_maths['F'] = self.grid_size
self.direction_maths['U'] = -self.direction_maths['D']
self.direction_maths['L'] = -self.direction_maths['R']
self.direction_maths['B'] = -self.direction_maths['F']
self.direction_maths[' '] = 0

def __repr__(self):
"""Format the data to allow it to be imported again as a new object."""
grid_data_joined = ''.join(str(i).ljust(1) for i in self.grid_data)
return "Connect3D.from_string('{}.{}')".format(grid_data_joined, self.current_player)

def __str__(self):
"""Use the grid_data to output a grid of the correct size.
Each value in grid_data must be 1 character or formatting will be wrong.

>>> grid_data = range(8)

>>> print Connect3D.from_string(''.join(str(i) if i != '' else ' ' for i in grid_data))
________
/ 0 / 1 /|
/___/___/ |
/ 2 / 3 /  |
/___/___/   |
|   |____|___|
|   / 4 /|5 /
|  /___/_|_/
| / 6 / 7|/
|/___/___|

"""
k = 0

grid_range = range(self.grid_size)
grid_output = []

if self._display_score:
grid_output.append(self.show_score())

for j in grid_range:

row_top = ' '*(self.grid_size*2+1) + '_'*(self.grid_size*4)
if j:
row_top = '|' + row_top[:self.grid_size*2-1] + '|' + '_'*(self.grid_size*2) + '|' + '_'*(self.grid_size*2-1) + '|'
grid_output.append(row_top)

for i in grid_range:
row_display = ' '*(self.grid_size*2-i*2) + '/' + ''.join((' ' + str(self.grid_data[k+x]).ljust(1) + ' /') for x in grid_range)
k += self.grid_size
row_bottom = ' '*(self.grid_size*2-i*2-1) + '/' + '___/'*self.grid_size

if j != grid_range[-1]:
row_display += ' '*(i*2) + '|'
row_bottom += ' '*(i*2+1) + '|'
if j:
row_display = row_display[:self.grid_size*4+1] + '|' + row_display[self.grid_size*4+2:]
row_bottom = row_bottom[:self.grid_size*4+1] + '|' + row_bottom[self.grid_size*4+2:]

row_display = '|' + row_display[1:]
row_bottom = '|' + row_bottom[1:]

grid_output += [row_display, row_bottom]

return '\n'.join(grid_output)

def _get_winning_player(self):
"""Return a list of the player(s) with the highest points.

>>> C3D = Connect3D()
>>> C3D.update_score()

When X has a higher score.
>>> C3D.current_points['X'] = 5
>>> C3D.current_points['O'] = 1
>>> C3D._get_winning_player()
['X']

When both scores are the same.
>>> C3D.current_points['O'] = 5
>>> C3D._get_winning_player()
['O', 'X']

When there are no winners.
>>> C3D = Connect3D()
>>> C3D.update_score()
>>> C3D._get_winning_player()
[]
"""
self.update_score()
return get_max_dict_keys(self.current_points)

@classmethod
def from_string(cls, raw_data):
"""Create new Connect3D instance from a string.

Parameters:
raw_data (str): Passed in from __repr__,
contains the grid data and current player.
Will still work if no player is defined.
Format: "joined(grid_data).current_player"
"""
split_data = raw_data.split('.')
grid_data = [i if i != ' ' else '' for i in split_data]
new_c3d_instance = cls(calculate_grid_size(grid_data))

new_c3d_instance.grid_data = grid_data

if len(split_data) > 1:
new_c3d_instance.current_player = split_data

return new_c3d_instance

@classmethod
def from_list(cls, grid_data, player=None):
"""Create new Connect3D instance from a list.

Parameters:
grid_data (list/tuple): 1D list of grid cells, amount must be a cube number.

player (int or None): Current player to continue the game with.
"""
new_c3d_instance = cls(calculate_grid_size(grid_data))

new_c3d_instance.grid_data = [i if i != ' ' else '' for i in grid_data]

if player is not None:
new_c3d_instance.current_player = player

return new_c3d_instance

def play(self, player1=True, player2=False, grid_shuffle_chance=None):
"""Start or continue a game.
If using computer players, there is a minimum time delay to avoid it instantly making moves.

Parameters:
player1 (bool): If player 1 is a human player.

player2 (bool): If player 2 is a human player.

grid_shuffle_chance (int, float or None, optional): Percentage chance to shuffle
the grid after each turn.
Reverts to the default chance if left as None.
"""

self.current_player = int(not self.current_player)
min_time_update = 0.1

#Game loop
while True:

current_time = time.time()

#Switch current player
self.current_player = int(not self.current_player)

was_flipped = self.shuffle(chance=grid_shuffle_chance)

#Display score and grid
self.update_score()
self._display_score = True
print self
self._display_score = False

if was_flipped:
print "Grid was flipped!"

#Check if no spaces are left
if '' not in self.grid_data:
winning_player = self._get_winning_player()
if len(winning_player) == 1:
print 'Player {} won!'.format(winning_player)
else:
print 'The game was a draw!'

#Ask to play again and check if answer is a variant of 'yes' or 'ok'
print 'Play again?'
play_again = raw_input().lower()
if any(i in play_again for i in ('y', 'k')):
self.reset()
else:
return
break

#Player takes a move, function returns True if it updates the grid, otherwise loop again
print "Player {}'s turn...".format(self.player_symbols[self.current_player])
if (player1 and not self.current_player) or (player2 and self.current_player):
while not self.make_move(self.player_symbols[self.current_player], raw_input().replace(',', ' ').replace('.', ' ').split()):
print "Grid cell is not available, try again."
else:
ai_go = SimpleC3DAI(self, self.current_player).calculate_next_move()
if not self.make_move(self.player_symbols[self.current_player], ai_go):
raise Connect3DError('Something unknown went wrong with the AI')
else:
print "AI moved to point {}.".format(PointConversion(self.grid_size, ai_go).to_3d())

#Wait a short while
time.sleep(max(0, min_time_update, time.time()-current_time))
print

def make_move(self, id, *args):
"""Update the grid data with a new move.

Parameters:
id (str): Character to write into the grid.

args (int, tuple or list): Where in the grid to place the ID.
Can be input as an integer (grid cell number), 3 integers,
a tuple or list (3D coordinates)

>>> C3D = Connect3D(2)

>>> C3D.make_move('a', 1)
True
>>> C3D.make_move('b', 1)
False
>>> C3D.make_move('c', -1)
False
>>> C3D.make_move('d', 2, 2, 2)
True
>>> C3D.make_move('e', [1, 1, 2])
True
>>> C3D.make_move('f', (1, 1, 3))
False

>>> C3D.grid_data
['', 'a', '', '', 'e', '', '', 'd']
>>> print C3D
________
/   / a /|
/___/___/ |
/   /   /  |
/___/___/   |
|   |____|___|
|   / e /|  /
|  /___/_|_/
| /   / d|/
|/___/___|
"""

#Convert points to the grid cell ID
if len(args) == 1:
if not str(args).replace('-','').isdigit():
if len(args) == 1:
try:
i = int(args)
except ValueError:
return False
else:
i = PointConversion(self.grid_size, args).to_int()
else:
i = int(args)
else:
i = PointConversion(self.grid_size, tuple(args)).to_int()

#Add to grid if cell is empty
if 0 <= i <len(self.grid_data) and not self.grid_data[i] and i is not None:
self.grid_data[i] = id
return True
else:
return False

def shuffle(self, chance=None, second_chance=None, repeats=None, no_shuffle=[]):
"""Mirror the grid in the X, Y, or Z axis.

Each time one of the directions is flipped, there is a 50% chance of it happening again.
This means it has the same overall chance to flip, but it is not limited to a single axis.

Parameters:
chance:
Percent chance of a flip happening.
Default: 10
Type: int/float

second_chance:
Percent chance of subsequent flips happening after the first.
Default: 50
Type: int/float

repeats:
Number of attempts to flip at the above chance.
Default: 3
Type: int

no_shuffle:
List of directions already flipped so it won't reverse anything.
Type: list
"""
#Set defaults
if chance is None:
chance = 33
if second_chance is None:
second_chance = 50
if repeats is None:
repeats = 3

#Calculate range of random numbers
chance = min(100, chance)
if chance > 0:
chance = int(round(400/chance))-1

#Attempt to flip grid
for i in range(repeats):
shuffle_num = random.randint(0, chance)
if shuffle_num in (0, 1, 2, 3) and shuffle_num not in no_shuffle:
no_shuffle.append(shuffle_num)
if shuffle_num == 0:
self.grid_data = SwapGridData(self.grid_data).x()
if shuffle_num == 1:
self.grid_data = SwapGridData(self.grid_data).y()
if shuffle_num == 2:
self.grid_data = SwapGridData(self.grid_data).z()
if shuffle_num == 3:
self.grid_data = SwapGridData(self.grid_data).reverse()
if self.shuffle(chance=second_chance, no_shuffle=no_shuffle) or not not no_shuffle:
return True

def update_score(self):
"""Recalculate the score.

There are 26 total directions from each point, or 13 lines, calculated in
the DirectionCalculation() class. For each of the 13 lines, look both ways
and count the number of values that match the current player.

This will find any matches from one point, so it's simple to then iterate
through every point. A hash of each line is stored to avoid duplicates.
"""

try:
self.grid_data_last_updated
except AttributeError:
self.grid_data_last_updated = None

if self.grid_data_last_updated != hash(tuple(self.grid_data)):

#Store hash of grid_data in it's current state to avoid unnecessarily running the code again when there's been no changes
self.grid_data_last_updated = hash(tuple(self.grid_data))

self.current_points = defaultdict(int)
all_matches = set()

#Loop through each point
for starting_point in range(len(self.grid_data)):

current_player = self.grid_data[starting_point]

if current_player:

for i in DirectionCalculation().opposite_direction:

#Get a list of directions and calculate movement amount
possible_directions = [list(i)]
possible_directions += [[j.replace(i, '') for i in possible_directions for j in DirectionCalculation().direction_group.values() if i in j]]
direction_movement = sum(self.direction_maths[j] for j in possible_directions)

#Build list of invalid directions
invalid_directions = [[self.direction_edges[j] for j in possible_directions[k]] for k in (0, 1)]
invalid_directions = [join_list(j) for j in invalid_directions]

num_matches = 1
list_match = [starting_point]

#Use two loops for the opposite directions
for j in (0, 1):

current_point = starting_point

while current_point not in invalid_directions[j] and 0 < current_point < len(self.grid_data):
current_point += direction_movement * int('-'[:j] + '1')
if self.grid_data[current_point] == current_player:
num_matches += 1
list_match.append(current_point)
else:
break

#Add a point if enough matches
if num_matches == self.grid_size:

list_match = hash(tuple(sorted(list_match)))
if list_match not in all_matches:
self.current_points[current_player] += 1

def show_score(self, digits=False, marker='/'):
"""Print the current points.

Parameters:
digits (bool, optional): If the score should be output as a number,
or as individual marks.

marker (str, optional): How each point should be displayed if
digits are not being used.

>>> C3D = Connect3D()
>>> C3D.update_score()
>>> C3D.current_points['X'] = 5
>>> C3D.current_points['O'] = 1

>>> C3D.show_score(False, '/')
'Player X: /////  Player O: /'
>>> C3D.show_score(True)
'Player X: 5  Player O: 1'
"""
self.update_score()
multiply_value = 1 if digits else marker
return 'Player X: {x}  Player O: {o}'.format(x=multiply_value*(self.current_points['X']), o=multiply_value*self.current_points['O'])

def reset(self):
"""Empty the grid without creating a new Connect3D object."""
self.grid_data = ['' for i in range(pow(self.grid_size, 3))]

class DirectionCalculation(object):
"""Calculate which directions are possible to move in, based on the 6 directions.
Any combination is fine, as long as it doesn't go back on itself, hence why X, Y
and Z have been given two values each, as opposed to just using six values.

Because the code to calculate score will look in one direction then reverse it,
the list then needs to be trimmed down to remove any duplicate directions (eg.
up/down and upright/downleft are both duplicates)

The code will output the following results, it is possible to use these instead of the class.
direction_group = {'Y': 'UD', 'X': 'LR', 'Z': 'FB', ' ': ' '}
opposite_direction = ('B', 'D', 'DF', 'LDB', 'DB', 'L', 'LUB', 'LUF', 'LF', 'RU', 'LB', 'LDF', 'RD')
"""

direction_group = {}
direction_group['X'] = 'LR'
direction_group['Y'] = 'UD'
direction_group['Z'] = 'FB'
direction_group[' '] = ' '

#Come up with all possible directions
all_directions = set()
for x in [' ', 'X']:
for y in [' ', 'Y']:
for z in [' ', 'Z']:
x_directions = list(direction_group[x])
y_directions = list(direction_group[y])
z_directions = list(direction_group[z])
for i in x_directions:
for j in y_directions:
for k in z_directions:

#Narrow list down to remove any opposite directions
opposite_direction = all_directions.copy()
for i in all_directions:
if i in opposite_direction:
new_direction = ''
for j in list(i):
for k in direction_group.values():
if j in k:
new_direction += k.replace(j, '')
opposite_direction.remove(new_direction)

class PointConversion(object):
"""Used to convert the cell ID to 3D coordinates or vice versa.
Mainly used for inputting the coordinates to make a move.

The cell ID is from 0 to grid_size^3, and coordinates are from 1 to grid_size.
This means an ID of 0 is actually (1,1,1), and 3 would be (4,1,1).

- X -
__1___2_
/  1/ 0 / 1 /|
Y   /___/___/ |
/  2/ 2 / 3 /  |
/___/___/   |
|   |____|___|
| 1|   / 4 /|5 /
Z  |  /___/_|_/
| 2| / 6 / 7|/
|/___/___|

Parameters:
grid_size:
Size of the grid.
Type: int

i:
Cell ID or coordinates.
Type int/tuple/list

Functions:
to_3d
to_int
"""
def __init__(self, grid_size, i):
self.grid_size = grid_size
self.i = i

def to_3d(self):
"""Convert cell ID to a 3D coordinate.

>>> grid_size = 4
>>> cell_id = 16

>>> PointConversion(grid_size, cell_id).to_3d()
(1, 1, 2)
"""
cell_id = int(self.i)
z = cell_id / pow(self.grid_size, 2)
cell_id %= pow(self.grid_size, 2)
y = cell_id / self.grid_size
x = cell_id % self.grid_size
return tuple(cell_id+1 for cell_id in (x, y, z))

def to_int(self):
"""Convert 3D coordinates to the cell ID.

>>> grid_size = 4
>>> coordinates = (4,2,3)

>>> PointConversion(grid_size, coordinates).to_int()
39
"""
x, y, z = [int(i) for i in self.i]
if all(i > 0 for i in (x, y, z)):
return (x-1)*pow(self.grid_size, 0) + (y-1)*pow(self.grid_size, 1) + (z-1)*pow(self.grid_size, 2)
return None

class SwapGridData(object):
"""Use the size of the grid to calculate how flip it on the X, Y, or Z axis.
The flips keep the grid intact but change the perspective of the game.

Parameters:
grid_data (list/tuple): 1D list of grid cells, amount must be a cube number.
"""
def __init__(self, grid_data):
self.grid_data = list(grid_data)
self.grid_size = calculate_grid_size(self.grid_data)

def x(self):
"""Flip on the X axis.

>>> SwapGridData(range(8)).x()
[1, 0, 3, 2, 5, 4, 7, 6]
>>> print Connect3D.from_list(SwapGridData(range(8)).x())
________
/ 1 / 0 /|
/___/___/ |
/ 3 / 2 /  |
/___/___/   |
|   |____|___|
|   / 5 /|4 /
|  /___/_|_/
| / 7 / 6|/
|/___/___|
"""
return join_list(x[::-1] for x in split_list(self.grid_data, self.grid_size))

def y(self):
"""Flip on the Y axis.

>>> SwapGridData(range(8)).y()
[2, 3, 0, 1, 6, 7, 4, 5]
>>> print Connect3D.from_list(SwapGridData(range(8)).y())
________
/ 2 / 3 /|
/___/___/ |
/ 0 / 1 /  |
/___/___/   |
|   |____|___|
|   / 6 /|7 /
|  /___/_|_/
| / 4 / 5|/
|/___/___|
"""
group_split = split_list(self.grid_data, pow(self.grid_size, 2))
return join_list(join_list(split_list(x, self.grid_size)[::-1]) for x in group_split)

def z(self):
"""Flip on the Z axis.

>>> SwapGridData(range(8)).z()
[4, 5, 6, 7, 0, 1, 2, 3]
>>> print Connect3D.from_list(SwapGridData(range(8)).z())
________
/ 4 / 5 /|
/___/___/ |
/ 6 / 7 /  |
/___/___/   |
|   |____|___|
|   / 0 /|1 /
|  /___/_|_/
| / 2 / 3|/
|/___/___|
"""
return join_list(split_list(self.grid_data, pow(self.grid_size, 2))[::-1])

def reverse(self):
"""Reverse the grid.

>>> SwapGridData(range(8)).reverse()
[7, 6, 5, 4, 3, 2, 1, 0]
>>> print Connect3D.from_list(SwapGridData(range(8)).reverse())
________
/ 7 / 6 /|
/___/___/ |
/ 5 / 4 /  |
/___/___/   |
|   |____|___|
|   / 3 /|2 /
|  /___/_|_/
| / 1 / 0|/
|/___/___|
"""
return self.grid_data[::-1]

def calculate_grid_size(grid_data):
"""Cube root the length of grid_data to find the grid size."""
return int(round(pow(len(grid_data), 1.0/3.0), 0))

def split_list(x, n):
"""Split a list by n characters."""
n = int(n)
return [x[i:i+n] for i in range(0, len(x), n)]

def join_list(x):
"""Convert nested lists into one single list."""
return [j for i in x for j in i]

def get_max_dict_keys(x):
"""Return a list of every key containing the max value.

Parameters:
x (dict): Dictionary to sort and get highest value.
It must be a dictionary of integers to work properly.
"""
if x:
sorted_dict = sorted(x.iteritems(), key=operator.itemgetter(1), reverse=True)
if sorted_dict:
return sorted([k for k, v in x.iteritems() if v == sorted_dict])
return []

class SimpleC3DAI(object):
"""AI coded to play Connect3D."""

def __init__(self, C3DObject, player_num):
"""Set up the AI for a single move using the current state of Connect3D."""
self.C3DObject = C3DObject
self.player_num = player_num
self.player = self.C3DObject.player_symbols[self.player_num]
self.enemy = self.C3DObject.player_symbols[int(not self.player_num)]
self.gd_len = len(self.C3DObject.grid_data)

def max_cell_points(self):
"""Get maximum number of points that can be gained from each empty cell,
that is not blocked by an enemy value.
"""
max_points = defaultdict(int)
filled_grid_data = [i if i else self.player for i in self.C3DObject.grid_data]
for cell_id in range(self.gd_len):
if cell_id == self.player:
max_points[cell_id] += self.check_grid(filled_grid_data, cell_id, self.player)
return get_max_dict_keys(max_points)

def check_for_n_minus_one(self, grid_data=None):
"""Find all places where anyone has n-1 points in a row, by substituting
in a point for each player in every cell.

Parameters:
grid_data (list or None, optional): Pass in a custom grid_data,
leave as None to use the Connect3D one.
"""
if grid_data is None:
grid_data = list(self.C3DObject.grid_data)

matches = defaultdict(list)
for cell_id in range(len(grid_data)):
if not grid_data[cell_id]:
for current_player in (self.player, self.enemy):
if self.check_grid(grid_data, cell_id, current_player):
matches[current_player].append(cell_id)
return matches

"""Look two moves ahead to detect if someone could get a point.
Uses the check_for_n_minus_one function from within a loop.

Will return 1 as the second parameter if it has looked up more than a single move.
"""
#Try initial check
match = self.check_for_n_minus_one()
if match:
return (match, 0)

#For every grid cell, substitute a player into it, then do the check again
grid_data = list(self.C3DObject.grid_data)
for i in range(self.gd_len):
if not self.C3DObject.grid_data[i]:
old_value = grid_data[i]
for current_player in (self.player, self.enemy):
grid_data[i] = current_player
match = self.check_for_n_minus_one(grid_data)
if match:
return (match, 1)
grid_data[i] = old_value

return (defaultdict(list), 0)

def check_grid(self, grid_data, cell_id, player):
"""Duplicate of the Connect3D.update_score method, but set up to check individual cells.

Parameters:
grid_data (list/tuple): 1D list of grid cells, amount must be a cube number.

cell_id (int): The cell ID, or grid_data index to update.

player (int): Integer representation of the player, can be 0 or 1.
"""
max_points = 0
for i in DirectionCalculation().opposite_direction:

#Get a list of directions and calculate movement amount
possible_directions = [list(i)]
possible_directions += [[j.replace(i, '') for i in possible_directions for j in DirectionCalculation().direction_group.values() if i in j]]
direction_movement = sum(self.C3DObject.direction_maths[j] for j in possible_directions)

#Build list of invalid directions
invalid_directions = [[self.C3DObject.direction_edges[j] for j in possible_directions[k]] for k in (0, 1)]
invalid_directions = [join_list(j) for j in invalid_directions]

num_matches = 1

#Use two loops for the opposite directions
for j in (0, 1):

current_point = cell_id

while current_point not in invalid_directions[j] and 0 < current_point < len(grid_data):
current_point += direction_movement * int('-'[:j] + '1')
if grid_data[current_point] == player:
num_matches += 1
else:
break

#Add a point if enough matches
if num_matches == self.C3DObject.grid_size:
max_points += 1

return max_points

def calculate_next_move(self):
"""Groups together the AI methods in order of importance.
Will throw an error if grid_data is full, since the game should have ended by then anyway.
"""

next_moves = []

if len(''.join(self.C3DObject.grid_data)) > (self.C3DObject.grid_size-2) * 2:

point_based_move, far_away = SimpleC3DAI(self.C3DObject, self.player_num).look_ahead()
order_of_importance = [self.enemy, self.player][::int('-'[:int(far_away)]+'1')]
grid_data_len = len(''.join(self.C3DObject.grid_data))

state = None

if point_based_move:
if point_based_move[self.enemy]:
next_moves = point_based_move[self.enemy]
state = 'Blocking opposing player'

elif point_based_move[self.player]:
next_moves = point_based_move[self.player]
state = 'Gaining points'

else:
next_moves = self.max_cell_points()
state = 'Random placement'

if not next_moves:
next_moves = [i for i in range(self.gd_len) if not self.C3DObject.grid_data[i]]
if state is None:
state = 'Struggling'

else:
next_moves = [i for i in range(self.gd_len) if not self.C3DObject.grid_data[i]]
state = 'Starting'

print 'AI Objective: ' + state + '.'

return random.choice(next_moves)

if __name__ == '__main__':
c3d = Connect3D()
c3d.play(True, False)


The AI works in a fairly simple way, and will check certain things in order of importance.

First off, if the opposing player has 3 in a row, that is the most urgent thing to stop. Then if the AI has any 3 of it's own points in a row, it'll complete the row.

If there are no rows of 3, the AI will then check if there's any rows of 2 that either it, or the opposing player can score a point from. Because this is less urgent, the AI will aim to complete its own row first before blocking the other player.

If there are literally no good moves, it will look at each empty cell and calculate the maximum number of rows that can cross through each one (taking into account the current points in the grid). It'll then pick one of the cells with the highest possible rows.

Finally, if even that fails, such as if there are spaces but no more points to be gained, it'll just place a point anywhere.

It's currently a little impossible to beat, even without the grid flipping, so I'm thinking of implementing a percentage chance where it might not notice a row, which will make it feel a little more human. If anyone would like to test what the sweet spot would be with that, please let me know.

## Style

Python has an official style guide, which you should read and follow. Some highlights:

• Import statements should be one module per line, in alphabetical order:

from collections import defaultdict
import itertools
import operator
import random

• Whitespace should be more consistent (e.g. a single blank line between method definitions, spaces after commas, spaces around operators).

• Lines should be 80 characters or fewer in length (and docstrings should be <= 72).

• Function names should be lowercase_with_underscores (i.e. draw_grid not DrawGrid) - and again, consistency is important.

## Documentation

Listing classes and functions (also methods, which you also refer to as functions) in the module docstring and methods in the class docstrings is:

1. Duplicated information; and
2. Redundant, as this basic data is available from e.g. dir(module) or dir(Class).

Instead, the docstrings should provide information that cannot simply be determined by introspection, like the purpose of the module/class; your method docstrings are pretty good, for example. I would be inclined to use an existing format rather than inventing my own, as this makes it easier to use tools like Sphinx to automatically generate HTML/PDF docs from the docstrings. I like the Google style:

def __init__(self, grid_size=4, _raw_data=None):
"""Set up the grid and which player goes first.

Parameters:
grid_size (int, optional): How long each side of the grid should
be. The game works best with even numbers, 4 is the default and
recommended.

_raw_data (str or None, optional): Passed in from __repr__,
contains the grid data and current player.
Format: 'joined(grid_data).current_player'

...

"""


Having test examples in the docstrings is great; I'd be inclined to add:

if __name__ == '__main__':
import doctest
doctest.testmod()


at the end of the script to run them if the file script is executed directly.

## Functionality

• It's not clear why DirectionCalculation, PointConversion and SwapGridData are separate classes; all contain functionality that really belongs to Connect3D, although this would make it quite a long class.

• Implementing DrawGrid as a separate function seems like a very odd choice; the fact that calling this is all that Connect3D.draw does is a bit of a red flag. I would have been inclined to make that the __str__ implementation, so you can simply print c3d rather than print c3d.draw().

• All of the switching to and from strings seems redundant - pick a canonical representation of your data and stick with it.

• It's odd to see a "private by convention" single-leading-underscore parameter like _raw_data. Is the user not supposed to provide it? In general, I would implement a from_string @classmethod to create a new instance from a string representation (see example below), rather than make it an optional __init__ argument.

• Python is dynamically typed, but it still seems a bit odd for player_symbols to be "str/list/tuple/int". Really, you only care if it's an iterable of two characters, so you could do:

player_one, player_two = player_symbols  # ValueError if not two characters
if player_one == player_two:
raise ValueError('symbols must be different')


If the user wants to pass in e.g. 12 for '1' and '2', let them do that conversion themselves before calling your method. Also, note that you calculate len(set(player_symbols)) == 1 twice, rather than reusing same_symbols.

How the from_string might work:

class Connect3D(object):

def __init__(self, grid_size):
self.grid_data = ['' for i in range(pow(grid_size, 3))]
self.current_player = 0
...  # carry out rest of grid_size-based setup

@classmethod
def from_string(cls, raw_data):
grid_data, current_player = raw_data.split('.')
grid_data = [i if i != ' ' else '' for i in grid_data]
grid = cls(calculate_grid_size(grid_data))  # create new instance
...  # populate the instance with grid_data and current_player
return grid  # return new instance


Now Connect3D.from_string(...) will create a new instance, populate it from the raw data and return it for use.

• Thanks a lot for the tips, I'm just trying to get some implemented now so I'll comment as I go along. DirectionCalculation literally only needs to be run once per code, and since PointConversion and SwapGridData all do specific things, I guessed they'd be better as separate classes. DrawGrid (admittedly I shouldn't have used capitals) was kind of meant to be a temporary thing with there being no UI, though I hadn't thought about using __str__, that seems a much better idea. I really like the from_string idea, though I'm kinda having a problem getting it working properly. Sep 3, 2015 at 11:06
• direction_edges/maths needs to be calculated once per grid_size, which was done in __init__. With your from_string idea, I'd need to calculate these again, but I don't want to copy and paste the code. I tried moving them into a new function called _init_after, and ran that from both __init__ and from_string, but I got the error message unbound method _init_after() must be called with Connect3D instance as first argument (got nothing instead). Would you happen to know how to fix it? I tried adding that as a classmethod but it didn't receive any values from self. Sep 3, 2015 at 11:11
• @Peter I've added an example of the sort of thing I mean - @classmethod is the canonical way to create an alternate constructor in Python. Sep 3, 2015 at 11:18
• @Peter good work! Now you can get the AI to play itself and end nuclear conflict. Sep 3, 2015 at 14:27
• @Peter note that the class method needs to return the new instance for that to work; I've edited the example accordingly. Sep 4, 2015 at 7:49