AI-based 2048 logo

Programming an AI-based 2048 game (Part 2: Game logic)

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')


This initialization may lead to the two representations below:

2 0 0
0 0 0

0 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

# 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:")

base_path = path.join(Constants.DATA_DIR_NAME,
                      '{}_{}'.format(Constants.GRID_NB_ROWS_COLUMNS, Constants.GRID_NB_ROWS_COLUMNS))

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. 🙂