The inspiration
Couple of months back came across a twitter post announcing a daily puzzle game. Maybe it was the FOMO created by the Wordle bandwagon (never played that), but I really liked this puzzle game. I've been a regular player of it since then, breaking the streak only 3-4 times. You can try out the original game here
Over the period I've been getting the itch to implement the game logic myself. I've also been dabbling in basic Python in recent times, so I thought why not implement it using Python Turtle module itself? And hence the post :-)
The game
The game idea is very simple. There are some tiles (5 x 5 grid) on the screen. All the tiles present in the bottom row are clickable (represented as solid color tiles), and any tile of same color connected to these bottom row tiles are also clickable (see the cover image for an example). When we click on these clickable tiles, the clicked tile as well as the connected tiles get removed from the screen. New connections are formed after every click, following the same earlier logic.
The game ends when all the tiles are removed from the screen in a given number of moves (the minimum moves needed to remove all the tiles).
The implementation
To keep it simple, we're not going to worry about the minimum moves part in this article, and will only implement the new game creation and its game play part. In the next article we will look at a crude implementation of how we can find out the minimum number of moves needed for a given game board.
You can check out the screen grab of the actual game play of this below
Setting up the screen
This part is pretty easy. We import the Turtle module and create the screen, and a turtle (named pen) for drawing everything on the screen.
import turtle
from random import choice
import settings
from Game import Game
def init_game_screen():
'''Init the turtle screen and create a pen for drawing on
that screen. Also, hiding the pen as we don't really need
to see it.
'''
screen = turtle.Screen()
screen.screensize(settings.canv_width(),
settings.canv_height(), 'midnight blue')
screen.setup(settings.win_width(), settings.win_height())
screen.title('Figure')
screen.tracer(0)
pen = turtle.Turtle()
pen.pensize(settings.OUTER_OUTLINE)
pen.penup()
pen.hideturtle()
return screen, pen
Here settings
is a simple module where the game constants, and some utility methods are present. Note that we've put the screen tracer value to 0 (screen.tracer(0)
) as we don't want the inbuilt turtle screen refresh delay to make our game sluggish.
Generating the board
We use the random
module to get a random color (from COLORS = ['hot pink', 'white', 'yellow', 'turquoise']
) for each tile. List comprehension makes the code shorter, or we can use nested for loops to get the same result.
def init_game_colors():
return [[choice(settings.COLORS) for _ in range(settings.MAX_ROWS)]
for _ in range(settings.MAX_COLS)]
Note that we are storing the tiles colors as rows of columns (colors[col][row]
will give us the color of a particular tile). This will helps us in breaking out early while iterating over the tiles during the game play (if there is no tile in a column at say row index 1, then there won't be any tile above it at indices 2, 3 etc. also).
def play():
screen, pen = init_game_screen()
game = {
'obj': None,
'colors': []
}
start_game(screen, pen, game)
screen.onkeypress(lambda: start_game(screen, pen, game, True), 'space')
screen.onkeypress(lambda: start_game(screen, pen, game), 'n')
screen.listen()
screen.mainloop()
if __name__ == '__main__':
play()
We've also added the options to start a new game, or to replay the current game. We've used the n
and the space
keys for the corresponding actions. The same screen and pen is reused over multiple game plays.
def start_game(screen, pen, game, replay=False):
if not replay or len(game['colors']) == 0:
game['colors'] = init_game_colors()
if game['obj'] is None:
game['obj'] = Game(game['colors'], screen, pen)
else:
game['obj'].reset(game['colors'])
pen.clear()
game['obj'].start()
Since the same function is being used for a new, or a replay game, we clear the drawings made by the pen (pen.clear()
) before actually starting the game. I wanted to reuse the existing game class obj even for a new game, that's why we see a reset()
method lurking there.
The Tile
class
The Tile
class just stores the information related to a particular tile, and should be self explanatory. The only important thing to note is the tile_id
, stored as (self._id
). Tile id is being saved as a tuple of form (row, col). This is why when we calculate the x, y position of the tile on the screen, we use self._id[1]
for getting the column index, and self._id[0]
for getting the row index. This is opposite to how we are iterating for generating the colors, or how we'll iterate during the game play. We can change this for consistency if we want to. I haven't changed it, as initially my iterations were columns of rows instead of the current rows of columns, and I am too lazy to change everything now.
import settings
class Tile:
def __init__(self):
self._id = None
self._clickable = False
self._color = None
self._shape = None
self._x = 0
self._y = 0
self._x_bounds = [0, 0]
self._y_bounds = [0, 0]
self._connections = []
def set_tile_props(self, tile_id, color=None):
self._id = tile_id
self._clickable = False
self._connections.clear()
if color:
self._color = color
index = settings.COLORS.index(color)
self._shape = settings.INNER_SHAPES[index]
self._x = (self._id[1] - (settings.MAX_COLS - 1) / 2) * \
(settings.OUTER_TILE_SIZE + settings.TILES_GAP)
self._y = (self._id[0] - (settings.MAX_ROWS - 1) / 2) * \
(settings.OUTER_TILE_SIZE + settings.TILES_GAP)
self._x_bounds[0] = self._x - settings.OUTER_TILE_SIZE / 2
self._x_bounds[1] = self._x + settings.OUTER_TILE_SIZE / 2
self._y_bounds[0] = self._y - settings.OUTER_TILE_SIZE / 2
self._y_bounds[1] = self._y + settings.OUTER_TILE_SIZE / 2
def add_connection(self, conn_id):
self._connections.append(conn_id)
def connections(self):
return self._connections
def in_bounds(self, x, y):
return self._x_bounds[0] <= x <= self._x_bounds[1] and \
self._y_bounds[0] <= y <= self._y_bounds[1]
def id(self):
return self._id
def color(self):
return self._color
def pos(self):
return self._x, self._y
def shape(self):
return self._shape
def clickable(self, can_click=None):
if can_click is not None:
self._clickable = can_click
else:
return self._clickable
The Game
class
This is the brain of the game. We will go through this class step by step.
The constructor
& the reset
method
from Tile import Tile
import settings
class Game:
def __init__(self, colors, screen, pen):
self.screen = screen
self.pen = pen
self.tiles = []
self.cache = []
self.colors = colors
self.clickables = []
self.connection_groups = []
self.moves = 0
def reset(self, colors):
self.tiles.clear()
self.colors = colors
self.clickables.clear()
self.connection_groups.clear()
self.moves = 0
The variables to note here are tiles
, cache
, clickables
& connection_groups
.
-
tiles
: stores the tiles currently being shown on the screen -
cache
: stores the tiles which have been removed from the screen (Don't really need it, but we'll be reusing the tiles for a new game or a replay, and hence the presence). -
clickables
: stores the ids of tiles which are clickable at the moment -
connections_groups
: is a list which stores lists of ids, of clickable interconnected tiles
The start
and other relevant methods
Below is the code for the start
method which internally calls create_tiles
& draw_board
methods. Notice that till now we haven't really stated listening to tiles clicks events (no point if the game hasn't started yet, right?). We start doing this by listening to screen clicks, and then figuring out which tile was clicked.
def start(self):
self.create_tiles()
self.draw_board()
self.write_text(0, -self.screen.window_height() /
2 + 100, 'Click any of the colored tiles', 18)
self.screen.onclick(self.on_screen_click)
def create_tiles(self):
for col in range(settings.MAX_COLS):
self.tiles.append([])
for row in range(settings.MAX_ROWS):
tile = self.cache.pop() if len(self.cache) > 0 else Tile()
tile.set_tile_props((row, col), self.colors[col][row]) # tile_id being set as (row, col)
self.tiles[col].append(tile)
def write_text(self, x, y, text, size):
if self.pen.color() != 'white':
self.pen.color('white')
self.pen.goto(x, y)
self.pen.write(text, align='center', font=('Courier', size, 'normal'))
The draw_board
method
This is an important method. Its job is to figure out the connections, draw the tiles and make the screen ready for the user. It's like a manager who is going to give the demo, hoping that everyone has done their job correctly.
We don't want no broken windows, do we? ;-)
def draw_board(self):
for col in range(settings.MAX_COLS):
self.process_tile(0, col)
self.draw_tiles()
self.write_text(0, self.screen.window_height() / 2 - 80, 'Figure', 42)
if len(self.clickables) == 0:
self.screen.onclick(None)
self.write_text(0, 80, 'Game Over', 36)
self.write_text(0, 50, f'Total {self.moves} moves', 20)
self.write_text(0, -60, 'Press "space" to replay', 20)
self.write_text(0, -90, 'Press "n" to start a new game', 20)
else:
self.write_text(0, self.screen.window_height() /
2 - 140, f'{self.moves} moves', 20)
self.screen.update()
Notice the self.screen.update()
call at the end. Since we had turned off the tracer while creating the screen, we will need to refresh the screen ourselves, the method call does precisely that.
The code below iterates over the bottom row of the tiles, and makes the tiles present as clickable. Every tile, in turn, figures out if we need to go further down the tree and find out other connectable tiles.
for col in range(settings.MAX_COLS):
self.process_tile(0, col)
The process_tile
& other related methods
This method figures out which of the tiles are interconnected. We need to call this method before calling draw_tiles
, as draw_tiles
will also draw the connections on the screen.
def get_node(self, row, col):
if 0 <= col <= settings.MAX_COLS - 1 and 0 <= row <= settings.MAX_ROWS - 1:
col_tiles = self.tiles[col]
return col_tiles[row] if row < len(col_tiles) else None
def process_tile(self, row, col):
curr_node = self.get_node(row, col)
if not curr_node or curr_node.clickable():
return
has_clickable_connections = {
'prev': self.connectable(curr_node, row, col - 1),
'next': self.connectable(curr_node, row, col + 1),
'below': self.connectable(curr_node, row - 1, col),
'above': self.connectable(curr_node, row + 1, col)
}
if row == 0 or True in has_clickable_connections.values():
curr_node.clickable(True)
if has_clickable_connections['next']:
curr_node.add_connection((row, col + 1))
if has_clickable_connections['above']:
curr_node.add_connection((row + 1, col))
if (row, col) not in self.clickables:
self.clickables.append((row, col))
found = False
for connections in self.connection_groups:
if (row, col) in connections:
found = True
break
if not found:
self.connection_groups.append([(row, col)])
for value in has_clickable_connections.values():
if isinstance(value, tuple):
for connections in self.connection_groups:
if (row, col) in connections and value not in connections:
connections.append(value)
break
self.process_tile(*value)
We get the current_node
and if the node is not there, or if it is already clickable then we return from the function as no further processing is needed.
curr_node = self.get_node(row, col)
if not curr_node or curr_node.clickable():
return
Then we look around the current node and see if we can become a clickable node by virtue of being connected to another clickable node of same color.
has_clickable_connections = {
'prev': self.connectable(curr_node, row, col - 1),
'next': self.connectable(curr_node, row, col + 1),
'below': self.connectable(curr_node, row - 1, col),
'above': self.connectable(curr_node, row + 1, col)
}
The connectable
method
def connectable(self, first_node, row, col):
other_node = self.get_node(row, col)
if other_node and first_node.color() == other_node.color():
if other_node.clickable():
return True
return (row, col)
The connectable
method returns true if the other node exists, is clickable and of the same color. If it is of the same color but not currently clickable, then it returns its calling card (the tile_id). We do this because we need to traverse the tree, and if possible, make this node also clickable.
Rest of the code in the process_tile
method is simply about making the current_node clickable based on above findings, and then traverse the tree based on whoever gave their calling cards. We also save the respective ids of the clickable and connected nodes in appropriate locations for game play.
if row == 0 or True in has_clickable_connections.values():
curr_node.clickable(True)
# Every clickable node will only store the forward connections (next or above)
if has_clickable_connections['next']:
curr_node.add_connection((row, col + 1))
if has_clickable_connections['above']:
curr_node.add_connection((row + 1, col))
if (row, col) not in self.clickables:
self.clickables.append((row, col))
# We find the appropriate list where this id is already present
found = False
for connections in self.connection_groups:
if (row, col) in connections:
found = True
break
if not found:
# Append a new list containing the tile_id to the connection_groups
self.connection_groups.append([(row, col)])
for value in has_clickable_connections.values():
# Here if we've a tuple, means we need to traverse that node
# Also, we need to add this node to the connection list
if isinstance(value, tuple):
for connections in self.connection_groups:
if (row, col) in connections and value not in connections:
connections.append(value)
break
# Recursive call to traverse this node
self.process_tile(*value)
We can optimize the finding of appropriate connection_group list where we need to append the next node. We can do this by finding and storing the list index from the previous call and use that later.
The draw_tiles
& draw_tile
method
This is quite straight forward.
def draw_tiles(self):
for col_tiles in self.tiles:
for tile in col_tiles:
if not tile: # there won't be any tile above it also, so break
break
self.draw_tile(tile)
def draw_tile(self, tile):
pen = self.pen
pos = tile.pos()
pen.goto(pos)
pen.shape('square')
pen.color(tile.color())
if not tile.clickable():
pen.fillcolor('midnight blue')
pen.shapesize(settings.OUTER_SIZE_MULTIPLIER,
settings.OUTER_SIZE_MULTIPLIER, settings.OUTER_OUTLINE)
pen.stamp()
if tile.clickable():
pen.color('midnight blue')
else:
pen.color(tile.color())
pen.shapesize(settings.INNER_SIZE_MULTIPLIER,
settings.INNER_SIZE_MULTIPLIER, settings.INNER_OUTLINE)
tilt = 0
if tile.shape() == 'diamond':
pen.shape('square')
pen.tilt(45)
tilt = -45
else:
pen.shape(tile.shape())
if tile.shape() == 'triangle':
pen.tilt(90)
tilt = -90
pen.stamp()
pen.tilt(tilt)
tile_id = tile.id()
connections = tile.connections()
if (tile_id[0], tile_id[1] + 1) in connections:
pen.goto(pos[0] + settings.OUTER_TILE_SIZE / 2, pos[1])
pen.color(tile.color())
pen.pendown()
pen.setx(pen.xcor() + settings.TILES_GAP)
pen.penup()
if (tile_id[0] + 1, tile_id[1]) in connections:
pen.goto(pos[0], pos[1] + settings.OUTER_TILE_SIZE / 2)
pen.color(tile.color())
pen.pendown()
pen.sety(pen.ycor() + settings.TILES_GAP)
pen.penup()
The on_screen_click
method
This is the method which gets called when we click anywhere on the screen. It gives us the (x, y) co-ordinates of the point where the click occurred.
def on_screen_click(self, x, y):
# Step size is nothing but one tile size + the gap
# between two consecutive tiles
extreme_x = (settings.MAX_COLS - 1) / 2 * \
settings.STEP_SIZE + settings.OUTER_TILE_SIZE / 2
extreme_y = (settings.MAX_ROWS - 1) / 2 * \
settings.STEP_SIZE + settings.OUTER_TILE_SIZE / 2
# If the click is within the tiles area, then only we'll proceed further
if -extreme_x <= x <= extreme_x and -extreme_y <= y <= extreme_y:
clicked_tile_id = None
# To proceed further, we only look at the clickable tiles
for clickable in self.clickables:
if self.tiles[clickable[1]][clickable[0]].in_bounds(x, y):
clicked_tile_id = clickable
break
if clicked_tile_id:
# Increment the total moves if we've a valid tile click
self.moves += 1
self.handle_tile_click(clicked_tile_id)
The handle_tile_click
method
Here we take care of the tile click by deleting that tile and the other connected tiles.
def handle_tile_click(self, tile_id):
# Find out the connection group which this tile belongs to
for connections in self.connection_groups:
if tile_id in connections:
# We'll be deleting the tiles from top to bottom so we sort
# in reverse order. The reason is the same, we don't want
# to delete a lower row index tile and then find out that we
# need to delete the above one as well
tiles_to_remove = sorted(connections, reverse=True)
# Make all the nodes unclickable as we'll be reprocessing
# all the remaining tiles
for clickable in self.clickables:
self.tiles[clickable[1]][clickable[0]].clickable(False)
for tile_to_remove in tiles_to_remove:
# using pop() to get the removed item so that we can add
# it to the cache
tile = self.tiles[tile_to_remove[1]].pop(tile_to_remove[0])
self.cache.append(tile)
# After removing a tile, we need to change the tile_id (basically
# the row index) of all the tiles above it
# Notice the appropriate use of row and col indices while getting
# the tile from tiles list, and while using it as tile_id (opposite)
for row in range(tile_to_remove[0], len(self.tiles[tile_to_remove[1]])):
self.tiles[tile_to_remove[1]][row].set_tile_props(
(row, tile_to_remove[1]))
break # if we found the appropriate connection_group then need to break
# Clear everything as we need to remake the connections and redraw the board
self.clickables.clear()
self.connection_groups.clear()
self.pen.clear()
self.draw_board()
The settings
module
DEF_TILE_SIZE = 20 # This is the default turtle size
MAX_COLS = 5
MAX_ROWS = 5
TILES_GAP = 12
OUTER_SIZE_MULTIPLIER = 2 # We make the tile 2X the default turtle size
OUTER_OUTLINE = 4
INNER_SIZE_MULTIPLIER = 0.6 # For the inner shapes (triangle, circle etc.)
INNER_OUTLINE = 1
COLORS = ['hot pink', 'white', 'yellow', 'turquoise']
INNER_SHAPES = ['triangle', 'square', 'circle', 'diamond']
OUTER_TILE_SIZE = DEF_TILE_SIZE * OUTER_SIZE_MULTIPLIER
INNER_TILE_SIZE = DEF_TILE_SIZE * INNER_SIZE_MULTIPLIER
STEP_SIZE = OUTER_TILE_SIZE + TILES_GAP
def canv_width():
return MAX_COLS * OUTER_TILE_SIZE + (MAX_COLS - 1) * TILES_GAP
def canv_height():
return MAX_ROWS * OUTER_TILE_SIZE + (MAX_ROWS - 1) * TILES_GAP
def win_width():
return canv_width() + 4 * OUTER_TILE_SIZE
def win_height():
return canv_height() + 8 * OUTER_TILE_SIZE
And that's it. The game is done.
Thanks for sticking through the article. Please feel free to reach out if you've any questions, or if you find any mistake anywhere.
Have fun playing the game :-)
Top comments (0)