As I was previously writing about, I am currently in the middle of developing a custom AI-based Python program able to play to the 2048 video game (you can check the first blog post of this series out if you haven’t read it yet).
So let’s state the obvious: if you want an algorithm to play to a video game, you first have to implement it, right? Basically, this means to “translate” the different rules of your video game, such as: What does it mean to win? What about loosing? What are the allowed moves? etc.
This second blog post presents the three most important classes (namely Game
, Grid
and History
) I defined to model the 2048 game logic. For those who are impatient to see some User Interfaces (UIs) and Neural Networks, please just hang in there because -spoiler alert- these topics are coming up in the next posts (actually UI presentation is coming up right away in the next blog post. 😉
Game class
At a given time, to model my 2048 game, I decided that I needed the following information:
- What are the current tiles and their positions? (
Grid
object); - What is the current game state? Is the game finished or continuing? (
ended_game
flag variable); - For how long the game has started/lasted? (
round_count
variable); - What is the current game score? (
current_score
variable); - What were the previous tiles and moves? (
History
object).
Between parentheses, I indicated the name of variables and/or data structures that I used. To summarize, the Game
class contains the definition of the following functions:
def __init__(self, grid, init_grid_with_two_tiles) def __repr__(self)
def play_one_direction(self, direction) def play_many_directions(self, direction_list) def check_win_or_loose(self) def save_game(self, base_path)
It should be noted that a call of the play_one_direction
function checks if the played move is valid, updates the Grid
and generates a new tile (2 or 4) on a free position.
The different function names are quite self-explanatory but you can still browse the source code of this Game class is available on Github if you want more details.
Grid class
I chose to use a regular numpy array in order to store the 2048 tiles (2, 4, 8, 16, etc.) and their positions.
For example, a 3×3 2048-grid will be initialized in the following manner (let’s assume that we randomly place two tiles to start):
grid = np.zeros((3,3), dtype='int64') grid.generate_new_number(grid.return_free_positions()) grid.generate_new_number(grid.return_free_positions())
This initialization may lead to the two representations below:
2 0 0
0 0 00 4 0
2 0 0 0 0 0 0 4 0
Matrix-based representations (left) are more readable for humans and can be used for debugging. Inline text representations (right) are more compact and will be used to store the different Grid
states (tiles and their positions for each Game
round).
The source code of the Grid class is also available on Github if you want more details.
History class
The History class is quite simple and only contains few functions. In addition to the basic functions __init__
and __repr__
, we also have at our disposal:
def add_grid_state(self, t_str_state, score)
This function adds a new Grid
snapshot to the History
object. A new Grid
state can either be a change regarding tiles, tile positions and/or score so we have to keep track of both aspects.
def add_direction_or_state(self, direction_or_state)
This function is only used to add a final new direction/state (in case of win or loss) to the History
.
def something_moved(self, previous_state)
This function aims at determining if at least one tile has moved between two Grid
snapshots (current, previous). This is useful to know if rendering should be updated, win/loss should be checked, score should be updated, etc.
The source code of the History class is also available on Github if you want more details.
Example of console output for a 2×2 Grid game
Next blog post will cover UI aspects.
However, in the meantime, we can already play a 2048 game!
Let’s write few lines in Main.py
:
# We create a new empty Grid object
grid = Grid(nb_rows_columns=Constants.GRID_NB_ROWS_COLUMNS)
# We create a Game from that Grid with two tiles to start
game = Game(grid, init_grid_with_two_tiles=True)
# A list of directions to be played (static, for testing purposes)
directions = [Directions.LEFT, Directions.RIGHT, Directions.UP, Directions.DOWN]
while not game.ended_game: # While the game is not finished
game.play_many_directions(directions) # We played one of the four directions
# Finally, we print the game history and save it in the data directory
print("Full History:")
print(game.history)
base_path = path.join(Constants.DATA_DIR_NAME,
'replays',
'{}_{}'.format(Constants.GRID_NB_ROWS_COLUMNS, Constants.GRID_NB_ROWS_COLUMNS))
game.save_game(base_path=base_path)
The console output I obtained (spoiler alert, I lost…):
Round: 0
Score: 0
0 2
2 0
Next direction to be played: Left
=======================================
Round: 1
Score: 0
2 2
2 0
Next direction to be played: Left
=======================================
Round: 2
Score: 4
4 2
2 0
Next direction to be played: Right
=======================================
Round: 3
Score: 4
4 2
2 2
Next direction to be played: Left
=======================================
Round: 4
Score: 8
4 2
4 2
Next direction to be played: Up
=======================================
Round: 5
Score: 20
8 4
2 0
Next direction to be played: Right
=======================================
Round: 6
Score: 20
8 4
4 2
Sorry, you loose...
The full History
can be displayed for investigation.
Each line contains the round number, the inline text grid representation and the direction played:
0 0 0 2 2 0 Left
1 0 2 2 2 0 Left
2 4 4 2 2 0 Right
3 4 4 2 2 2 Left
4 8 4 2 4 2 Up
5 20 8 4 2 0 Right
6 20 8 4 4 2 LOOSE
As a reminder, I do not guarantee that my solution is the best or the most optimized one. Feel free to propose yours or any suggestion you may have by commenting this series of blog posts.
I hope you enjoyed reading this blog post and that you are impatient for the next blog posts. 🙂