UMPSA STEM LAB · Step 10 of 10 · Extension
Teach a computer to play Snake by itself using Q-Learning — a Reinforcement Learning algorithm. No AI library needed, just dictionaries and loops.
Instead of a human pressing keys, an AI agent chooses actions by looking up a table of learned values (the Q-table). Over thousands of games, it learns which actions lead to reward and which lead to death.
Reinforcement Learning is how humans and animals learn: try something, get feedback (reward or punishment), do more of what worked. This exact pattern is used in: AlphaGo (chess/Go), self-driving car training, recommendation algorithms, robot control, and ChatGPT's RLHF fine-tuning. Recognising this pattern means you understand the core of modern AI.
The Q-learner needs to know the current situation (state). We can't use raw coordinates — the grid is too large. Instead we use 11 boolean features that capture what matters.
Choosing what information to give the AI is called feature engineering. Raw pixel values = 20×15 = 300 numbers (too many). Our 11-bit state = 2^11 = 2048 possible states (manageable). This is abstraction applied to machine learning: extract only what's relevant, discard the rest.
def get_state(snake, food, direction):
"""
Return an 11-element tuple of booleans representing the game state.
This tuple becomes the KEY in our Q-table dictionary.
"""
hx, hy = snake.head()
# 3 danger sensors: is there immediate danger straight/left/right?
dir_r = (direction == "RIGHT")
dir_l = (direction == "LEFT")
dir_u = (direction == "UP")
dir_d = (direction == "DOWN")
# Check danger in relative directions
def is_wall_or_body(x, y):
"""True if (x, y) is out of bounds or snake body."""
if x < 0 or x >= GRID_COLS or y < 0 or y >= GRID_ROWS:
return True
return (x, y) in snake.body[1:]
state = (
# Danger straight, right, left (relative to current direction)
is_wall_or_body(hx + (dir_r - dir_l), hy + (dir_d - dir_u)), # danger ahead
is_wall_or_body(hx + (dir_d - dir_u), hy - (dir_r - dir_l)), # danger right
is_wall_or_body(hx - (dir_d - dir_u), hy + (dir_r - dir_l)), # danger left
# Current movement direction (one-hot encoded)
dir_r, dir_l, dir_u, dir_d,
# Food direction relative to head
food.pos[0] < hx, # food is to the left
food.pos[0] > hx, # food is to the right
food.pos[1] < hy, # food is above
food.pos[1] > hy, # food is below
)
return state # a tuple of 11 True/False values
The Q-learner sees the game as 11 True/False features. Click the arrow buttons to move the snake head. Watch which features flip as danger and food directions change — this tuple becomes the Q-table key.
The Q-table maps (state, action) pairs to a numeric value — how good is it to take this action in this situation? Higher Q-value = better action.
| State (11 booleans) | Q[STRAIGHT] | Q[LEFT] | Q[RIGHT] |
|---|---|---|---|
| (F,F,F, T,F,F,F, T,F,F,F) | 8.3 | 2.1 | -5.2 |
| (T,F,F, T,F,F,F, T,F,F,F) | -12.0 | 6.7 | 1.2 |
| (F,T,F, F,F,T,F, F,T,F,T) | 3.4 | -8.1 | 9.2 |
The agent picks the action with the highest Q-value for the current state. In Python, this table is just a defaultdict:
from collections import defaultdict
import random
# Q-table: maps (state, action) → float
# defaultdict(float) returns 0.0 for any new key
q_table = defaultdict(float)
ACTIONS = [0, 1, 2] # 0=straight, 1=turn-left, 2=turn-right
EPSILON = 0.1 # exploration rate (10% random, 90% best known)
def choose_action(state):
"""ε-greedy: explore randomly 10% of the time, exploit best 90%."""
if random.random() < EPSILON:
return random.choice(ACTIONS) # explore!
q_vals = [q_table[(state, a)] for a in ACTIONS]
return ACTIONS[q_vals.index(max(q_vals))] # exploit!
After every action, we update the Q-value using the Bellman equation. New estimate = immediate reward + discounted future reward.
ALPHA = 0.1 # learning rate: how fast we update Q values
GAMMA = 0.9 # discount factor: how much we value future rewards
def update_q(state, action, reward, next_state):
"""
Q-Learning update rule (Bellman equation):
Q(s,a) ← Q(s,a) + α × [r + γ × max(Q(s',a')) − Q(s,a)]
"""
old_q = q_table[(state, action)]
# Best possible future reward from next_state
best_future = max(q_table[(next_state, a)] for a in ACTIONS)
# Bellman target
target = reward + GAMMA * best_future
# Update Q-value (weighted average with learning rate)
q_table[(state, action)] = old_q + ALPHA * (target - old_q)
# Reward signal:
# +10 = ate food (reward good behaviour)
# -10 = died (punish bad behaviour)
# -0.1 = each step (small penalty encourages efficiency)
The Bellman equation says: the value of taking action A in state S equals the immediate reward PLUS the best possible reward from the resulting state S'. The discount factor γ (gamma) makes near rewards more valuable than distant ones — just like humans prefer $100 today over $100 next year. This one equation is the foundation of Q-Learning, Deep Q-Networks (DQN), and RLHF (how ChatGPT is trained).
Instead of one human playing, we run thousands of games in a loop. Each game updates the Q-table. After enough games, the agent learns to play well.
import pygame
from collections import defaultdict
# (All class definitions from Step 8 go here)
q_table = defaultdict(float)
ALPHA = 0.1; GAMMA = 0.9; EPSILON = 0.1
# Map relative action (0,1,2) to absolute direction
TURNS = {
"RIGHT": ["RIGHT","DOWN","UP"],
"LEFT": ["LEFT","UP","DOWN"],
"UP": ["UP","RIGHT","LEFT"],
"DOWN": ["DOWN","LEFT","RIGHT"],
}
def train(n_episodes=10000, visual=False):
"""Train the Q-learner over n_episodes games."""
pygame.init()
screen = pygame.display.set_mode(
(GRID_COLS*CELL_SIZE, GRID_ROWS*CELL_SIZE)) if visual else None
clock = pygame.time.Clock()
scores = []
for episode in range(n_episodes):
game = Game()
state = get_state(game.snake, game.food, game.snake.direction)
steps = 0
while game.running and steps < 1000: # cap steps to avoid loops
action = choose_action(state)
new_dir = TURNS[game.snake.direction][action]
game.snake.direction = new_dir
old_score = game.score
game.update()
steps += 1
if game.score > old_score:
reward = 10 # ate food
elif not game.running:
reward = -10 # died
else:
reward = -0.1 # still alive, small penalty
next_state = get_state(game.snake, game.food,
game.snake.direction)
update_q(state, action, reward, next_state)
state = next_state
if visual:
game.draw(); clock.tick(60)
scores.append(game.score)
if episode % 1000 == 0:
avg = sum(scores[-100:])/min(100, len(scores))
print(f"Episode {episode:5d} | Avg score: {avg:.2f}"
f" | Q-table size: {len(q_table)}")
pygame.quit()
return scores
if __name__ == "__main__":
train(n_episodes=5000, visual=True)
Early in training the snake dies quickly (Q-table is all zeros, actions are random). After a few thousand games, patterns emerge.
| Episode range | Avg score | What's happening |
|---|---|---|
| 0 – 100 | ≈ 0.5 | Mostly random — dies in a few steps |
| 100 – 1000 | ≈ 2–5 | Learns to avoid immediate walls |
| 1000 – 5000 | ≈ 5–15 | Learns to navigate toward food |
| 5000 – 20000 | ≈ 15–30 | Starts to avoid self-collisions |
| 20000+ | ≈ 30+ | Sophisticated strategy emerges |
Q-Learning, Bellman equation, state representation, epsilon-greedy exploration, reward engineering, and training loops — the same concepts used in professional AI research.
Replace the Q-table with a neural network → Deep Q-Network (DQN). Use a CNN on raw pixels → matches DeepMind's Atari paper. Add experience replay and target networks → modern stable RL training.
Look back at every step. Decomposition (Step 5: functions), Pattern Recognition (modulo, RL loops), Abstraction (OOP, feature engineering), Algorithm Design (Bellman equation, collision detection) — these four CT skills are exactly the skills used by AI researchers, game developers, and software engineers. You didn't just learn to code a Snake game. You learned to think computationally.
After training, print the 5 (state, action) pairs with the highest Q-values: sorted(q_table.items(), key=lambda x: x[1], reverse=True)[:5]. What do the top states have in common?
Save episode scores to a list. Use matplotlib.pyplot.plot(scores) after training to visualise how the agent improves over time. Install matplotlib: pip install matplotlib.
Use the json module from Step 7 to save q_table as a JSON file after training, and load it before playing. You'll need to convert tuple keys to strings: str(key).
You went from print("Hello, World!") to building and training an AI agent — in 9 steps. That's remarkable.
Variables, loops, conditionals, lists, functions, file I/O, OOP, and machine learning — the full stack of programming knowledge, applied to a real game.
Tetris, Pac-Man, a platformer — you have all the tools. Or explore pygame's network module for multiplayer, or PyTorch for a real neural network agent.
These are the errors beginners make most often in Step 10. Read them now so you can recognise them in your own code.
LEARNING_RATE = 1.0 # replaces Q-value completely each update
LEARNING_RATE = 0.1 # blend 10% new info with 90% existing knowledge
EPSILON = 0.3 # fixed: agent always explores 30% of the time
epsilon = max(0.01, epsilon * 0.995) # decay: explore less as agent learns
# Trying to store the state IN the action variable
state = get_state(snake, food) # observe the world action = choose_action(state) # decide what to do q_table[state][action] += ... # update the value of that choice
Three questions — not graded. They help you spot gaps before the activities.
You've covered all the concepts for Step 10. Time to apply them.
Start Activities → Tier 1 → 2 → 3 → 4