Games and Programming in the Wild

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 2

The 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 merged

Walk 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. transpose is a nested list comprehension that builds a new row for each column of the original board. reverse_rows uses 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 = None

The 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 pyxel
import copy
SIZE = 4
TILE = 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,
  • 0 means an empty square,
  • tiles slide and equal neighbours merge once per move,
  • a new tile appears only after a real move,
  • Z undoes the previous real move,
  • and R starts over.

Game rules to implement:

  • the board starts with two random tiles,
  • a random new tile has value 2 most of the time and value 4 occasionally,
  • 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:

ClassContract
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_downeach 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
Appowns one TwentyFortyEightGame, handles Pyxel input, random tile selection, and drawing

Pyxel integration requirements:

  • App.update() maps arrow-key presses to game.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.rndi or Python’s random library,
  • App.update() maps pyxel.KEY_Z to game.undo(),
  • App.update() maps pyxel.KEY_R to 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 pyxel from Board or TwentyFortyEightGame,
  • 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

  1. What makes 2048 a turn-based game rather than a real-time one?
  2. The merge logic is written only for sliding a row left. How do the other three directions reuse it?
  3. Why does the code jump i forward by 2 after merging two tiles?
  4. Why does a new random tile appear only when moved != self.board?
  5. Why is copy.deepcopy needed 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.

Diagrams tried: 0