2048
Learning Objectives
- You can build a turn-based game where the state changes only on a key press.
- You can implement the slide-and-merge rule on one row, then reuse it for all four directions.
- You can save a snapshot of the game state and restore it to implement undo.
- You can explain why undo is easy in a turn-based game and hard in a real-time one.
Most of the seven games before this one were real-time: the state changed thirty times a second whether or not you touched anything. Rocks drifted, balls bounced, the lander fell. The next game is different. Nothing moves until you press a key, and each press is a complete, discrete turn. This change — from continuous to turn-based — is the new idea of the chapter, and it unlocks a feature the real-time games could not easily have: undo.
2048 is the sliding-tile puzzle where you push numbered tiles in one of four directions; tiles that bump into an equal tile merge into their sum. You start with a couple of 2s and try to build up to a 2048 tile.
The State Is One Grid
The whole game is a 4-by-4 grid of integers, where 0 means an empty square. That is the complete state — there is nothing else to track. It is a 2D list, the same shape as the Game of Life’s grid, holding numbers instead of booleans:
SIZE = 4
def empty_board(): return [[0] * SIZE for _ in range(SIZE)]A new game places two starting tiles. Adding a random tile means finding the empty squares and putting a 2 (or occasionally a 4) in one of them:
def add_random_tile(board): empties = [(r, c) for r in range(SIZE) for c in range(SIZE) if board[r][c] == 0] if empties: r, c = empties[pyxel.rndi(0, len(empties) - 1)] board[r][c] = 4 if pyxel.rndi(0, 9) == 0 else 2The list comprehension gathers the coordinates of every empty square — a list of (row, col) pairs — and we pick one at random. The 4 if ... else 2 gives a 4 one time in ten, the standard 2048 rule.
The Heart: Sliding One Row
The trickiest logic in the game is the slide-and-merge rule, and the key insight is to solve it for a single row sliding left, then build everything else out of that one function. Sliding a row left has three steps: drop the zeros (tiles slide to fill gaps), merge equal neighbours, then pad back out with zeros.
def slide_row_left(row): nums = [n for n in row if n != 0] # 1. remove gaps merged = [] i = 0 while i < len(nums): # 2. merge equal neighbours if i + 1 < len(nums) and nums[i] == nums[i + 1]: merged.append(nums[i] * 2) i = i + 2 # skip the tile we merged into else: merged.append(nums[i]) i = i + 1 while len(merged) < SIZE: # 3. pad with zeros merged.append(0) return mergedWalk through [2, 2, 4, 0]. Step 1 drops the zero, giving [2, 2, 4]. Step 2 walks left to right: the first two 2s are equal, so they merge into 4 and we jump past both; the lone 4 has nothing to merge with, so it carries over. That gives [4, 4]. Step 3 pads to [4, 4, 0, 0]. The i = i + 2 after a merge is what stops a tile from merging twice in one move — a subtle rule that [2, 2, 2, 2] would get wrong without it (it should become [4, 4, 0, 0], not [8, 0, 0, 0]).
This is the key item from the Functions Help Organize Code chapter. By solving the genuinely hard part once — one row, one direction — we get a tested building block. The four directions are then trivial transformations on top of it, as you are about to see. If we had tried to write “slide up” and “slide right” from scratch, we would have written the same fiddly merge logic four times, with four chances to get it subtly wrong.
Four Directions From One
Here is the payoff. Sliding the whole board left is just sliding every row left:
def slide_left(board): return [slide_row_left(row) for row in board]The other three directions reuse slide_left by rearranging the board first, sliding, and rearranging back. Two helpers do the rearranging — reversing each row (a left-right mirror) and transposing (swapping rows and columns, a flip across the diagonal):
def reverse_rows(board): return [row[::-1] for row in board]
def transpose(board): return [[board[r][c] for r in range(SIZE)] for c in range(SIZE)]These two helpers are intentionally provided code: you are expected to trace and reuse them, not invent the transformations from scratch.
transposeis a nested list comprehension that builds a new row for each column of the original board.reverse_rowsuses a slice-reversal trick to reverse each row. Together they turn any direction into a left-slide.
def move_left(board): return slide_left(board)
def move_right(board): return reverse_rows(slide_left(reverse_rows(board)))
def move_up(board): return transpose(slide_left(transpose(board)))
def move_down(board): return transpose(reverse_rows(slide_left(reverse_rows(transpose(board)))))Read move_right: mirror the board so right becomes left, slide left, mirror back. move_up: transpose so up becomes left, slide left, transpose back. Each direction is “transform the board into the left-sliding orientation, slide, transform back”. One hard function, reused four ways through clever rearrangement. This is elegant, but it is also a good example of “clever” code that can be hard to read.
A Turn: Only Change State on a Real Move
Because the game is turn-based, the update step does nothing most frames. It waits for an arrow key, computes the new board, and — importantly — only commits the move if it actually changed something:
def update(self): moved = None if pyxel.btnp(pyxel.KEY_LEFT): moved = move_left(self.board) elif pyxel.btnp(pyxel.KEY_RIGHT): moved = move_right(self.board) elif pyxel.btnp(pyxel.KEY_UP): moved = move_up(self.board) elif pyxel.btnp(pyxel.KEY_DOWN): moved = move_down(self.board)
if moved is not None and moved != self.board: self.prev_board = copy.deepcopy(self.board) # save for undo self.board = moved add_random_tile(self.board)The check moved != self.board is important: a new tile should appear only after a move that changed the board. Pressing left when everything is already pushed left is not a turn — nothing slid, so no new tile, and the player is not penalised for it. Comparing the two boards with != works because Python compares nested lists element by element.
Undo: Why Turn-Based Makes It Easy
Now the feature that real-time games could not easily offer. Because the state is one clean value and it changes only at discrete turns, undo is almost free: before each move, save a copy of the board; to undo, put the copy back.
import copy
# before a move:self.prev_board = copy.deepcopy(self.board)
# to undo, on pressing Z:if pyxel.btnp(pyxel.KEY_Z) and self.prev_board is not None: self.board = self.prev_board self.prev_board = NoneThe copy.deepcopy is the key to undo. It makes a full copy of the board so that later changes do not affect the saved snapshot. The board is a list of lists, and a plain copy would share the inner rows with the original — changing the board later would corrupt the saved snapshot. Using copy.deepcopy makes a fully independent copy, all the way down to the innermost elements, so the snapshot is truly frozen in time.
This is why undo is easy here and would be painful in, say, the Lunar Lander. In a real-time game the “state” is spread across many variables changing continuously; to undo, you would have to snapshot all of it every frame and decide what “one step back” even means in a continuous flow. In a turn-based game the state is one value and the turns are discrete, so “save a copy before each turn, restore it to go back” is the whole of it.
The Complete Game
import pyxelimport copy
SIZE = 4TILE = 32
def empty_board(): return [[0] * SIZE for _ in range(SIZE)]
def add_random_tile(board): empties = [(r, c) for r in range(SIZE) for c in range(SIZE) if board[r][c] == 0] if empties: r, c = empties[pyxel.rndi(0, len(empties) - 1)] board[r][c] = 4 if pyxel.rndi(0, 9) == 0 else 2
def slide_row_left(row): nums = [n for n in row if n != 0] merged = [] i = 0 while i < len(nums): if i + 1 < len(nums) and nums[i] == nums[i + 1]: merged.append(nums[i] * 2) i = i + 2 else: merged.append(nums[i]) i = i + 1 while len(merged) < SIZE: merged.append(0) return merged
def slide_left(board): return [slide_row_left(row) for row in board]
def reverse_rows(board): return [row[::-1] for row in board]
def transpose(board): return [[board[r][c] for r in range(SIZE)] for c in range(SIZE)]
def move_left(board): return slide_left(board)
def move_right(board): return reverse_rows(slide_left(reverse_rows(board)))
def move_up(board): return transpose(slide_left(transpose(board)))
def move_down(board): return transpose(reverse_rows(slide_left(reverse_rows(transpose(board)))))
class App: def __init__(self): self.reset() pyxel.init(SIZE * TILE, SIZE * TILE + 16, title="2048") pyxel.run(self.update, self.draw)
def reset(self): self.board = empty_board() self.prev_board = None add_random_tile(self.board) add_random_tile(self.board)
def update(self): if pyxel.btnp(pyxel.KEY_R): self.reset() return
moved = None if pyxel.btnp(pyxel.KEY_LEFT): moved = move_left(self.board) elif pyxel.btnp(pyxel.KEY_RIGHT): moved = move_right(self.board) elif pyxel.btnp(pyxel.KEY_UP): moved = move_up(self.board) elif pyxel.btnp(pyxel.KEY_DOWN): moved = move_down(self.board)
if moved is not None and moved != self.board: self.prev_board = copy.deepcopy(self.board) self.board = moved add_random_tile(self.board)
if pyxel.btnp(pyxel.KEY_Z) and self.prev_board is not None: self.board = self.prev_board self.prev_board = None
def draw(self): pyxel.cls(1) for r in range(SIZE): for c in range(SIZE): value = self.board[r][c] if value != 0: pyxel.rect(c * TILE + 1, r * TILE + 1, TILE - 2, TILE - 2, 10) pyxel.text(c * TILE + 10, r * TILE + 14, str(value), 0) else: pyxel.rectb(c * TILE + 1, r * TILE + 1, TILE - 2, TILE - 2, 5) pyxel.text(4, SIZE * TILE + 4, "Z undo R restart", 7)
if __name__ == "__main__": App()The drawing is deliberately plain — every tile the same colour, the number in the middle. A polished 2048 colours each value differently, and that is a good first thing to add. But the logic underneath is complete and correct: tiles slide, equal tiles merge once, a new tile appears only on a real move, and undo steps back one turn. The hard part, the slide-and-merge, was written once and reused four ways. That reuse, and the one-line undo that fell out of keeping the state clean, are the lessons worth carrying out of this chapter.
2048 with classes
0 / 40 points
Refactor the 2048 game from the chapter into a class-based Pyxel program.
Keep the chapter mechanics:
- the board is a 4-by-4 grid of integers,
0means an empty square,- tiles slide and equal neighbours merge once per move,
- a new tile appears only after a real move,
Zundoes the previous real move,- and
Rstarts over.
Game rules to implement:
- the board starts with two random tiles,
- a random new tile has value
2most of the time and value4occasionally, - a move first slides all non-zero tiles as far as possible in the chosen direction,
- adjacent equal tiles merge into one tile with double the value,
- each tile can participate in at most one merge during one move,
- zeros fill the spaces left behind after sliding and merging,
- a new random tile is added only if the move changed the board,
- a move that does not change the board must not save undo state or add a new tile,
- undo restores the board from before the previous real move and can be used only once per saved move,
- and reset clears undo state and starts a fresh board.
The mechanics must live in model classes that can be tested without Pyxel. The Pyxel App should handle keyboard input, random tile selection, and drawing.
Required classes:
| Class | Contract |
|---|---|
Board(rows=None, size=4) | stores a copied 2D list as rows and the board size |
Board.empty_positions(self) | returns all empty (row, col) positions; their order does not matter |
Board.add_tile(self, row, col, value) | places a tile value on the board |
Board.slide_row_left(self, row) | returns one row after the 2048 left-slide and merge rule |
Board.move_left(self) / move_right / move_up / move_down | each returns a new Board |
Board.copy(self) | returns an independent copy |
TwentyFortyEightGame(board=None) | owns board and prev_board |
TwentyFortyEightGame.moved_board(self, direction) | returns the post-move Board without changing the game state; both move and App can reuse this preview |
TwentyFortyEightGame.move(self, direction, new_tile=None) | applies one direction; saves undo and adds new_tile only when the board changes; returns whether movement happened; new_tile is a (row, col, value) tuple or None |
TwentyFortyEightGame.undo(self) | restores the saved previous board and returns whether undo happened |
TwentyFortyEightGame.reset(self, start_tiles=None) | creates an empty board and optionally adds the given starting tiles, a list of (row, col, value) tuples |
App | owns one TwentyFortyEightGame, handles Pyxel input, random tile selection, and drawing |
Pyxel integration requirements:
App.update()maps arrow-key presses togame.move("left"),"right","up", or"down",- when a move would change the board, the Pyxel layer chooses a random empty tile for the post-move board and passes it to
game.move(direction, new_tile), - random tile selection may use
pyxel.rndior Python’srandomlibrary, App.update()mapspyxel.KEY_Ztogame.undo(),App.update()mapspyxel.KEY_Rto reset,App.draw()shows the current board clearly enough for the player to tell where each tile is and what value it has; the visual design is up to you,- do not call
pyxelfromBoardorTwentyFortyEightGame, - do not start the app when the file is imported,
- and guard the launch with
if __name__ == "__main__": App().
The chapter provides the row-reversal and transpose patterns used to turn a
left move into the other three directions. Reuse those supplied patterns in
your Board methods; you are expected to trace and adapt them, not invent a
different matrix transformation from scratch.
Check Your Understanding
- What makes 2048 a turn-based game rather than a real-time one?
- The merge logic is written only for sliding a row left. How do the other three directions reuse it?
- Why does the code jump
iforward by 2 after merging two tiles? - Why does a new random tile appear only when
moved != self.board? - Why is
copy.deepcopyneeded for the undo snapshot rather than an ordinary copy? And why is undo so much easier here than it would be in the Lunar Lander?
AI Study Aid
Create a chapter diagram
We're looking into to what extent AI-generated diagrams could help with studying.
Use this study aid to generate an AI-generated visual summary of the material you just studied.
Each diagram style emphasizes the content in a different way, so you can choose the focus
that feels most useful.
Using the diagram generator is voluntary and does not affect your course progress. We encourage
you to try it out and see if it helps, but it's totally up to you! Your answers help us understand
how to make better study aids in the future.