Project 5.10: "Skill Game with App"
What you’ll learn
- ✅ App control: Move a player in a simple maze using app commands (FWD, BACK, LEFT, RIGHT, STOP).
- ✅ Reaction timing: Measure time between sensor/app signals and print a score.
- ✅ Scoring system: Keep score, lives, and levels with clean app messages.
- ✅ Feedback: Use LED/buzzer and OLED text to celebrate wins or show misses.
- ✅ Multiplayer: Alternate turns between Player A and Player B and track their scores.
Key ideas
- Short definition: A skill game is a loop of input → decision → feedback → score.
- Real‑world link: Arcade games and training apps measure reaction and accuracy with instant feedback.
Blocks glossary (used in this project)
- Bluetooth init + callback: Receive app messages and run code right away.
- Variables: Store player position, score, lives, level, and current turn.
- OLED shows / line / point: Draw maze walls and the player dot.
- Digital output (LED/Buzzer): Turn ON/OFF to show win or miss.
- Serial println: Print “KEY:VALUE” status for the app (SCORE, LIVES, LEVEL, TURN).
What you need
| Part | How many? | Pin connection |
|---|---|---|
| D1 R32 | 1 | USB cable (30 cm) |
| 0.96″ OLED (128×64) SSD1306 | 1 | I2C: SCL → Pin 22, SDA → Pin 21, VCC, GND |
| 10mm LED module | 1 | Signal → Pin 13, VCC, GND |
| Passive buzzer module | 1 | Signal → Pin 23, VCC, GND |
| Smartphone (Bluetooth) | 1 | Connect to device named “Clu‑Bots” |
🔌 Wiring tip: OLED uses I2C on pins 22/21. Use Pin 13 for LED and Pin 23 for buzzer. Keep wires short and secure.
📍 Pin map snapshot: 22/21 (OLED), 13 (LED), 23 (buzzer). Other pins are free.
Before you start
- USB cable is plugged in
- Serial monitor is open
- Test print shows:
print("Ready!") # Confirm serial is working so you can see messages
Microprojects 1–5
Microproject 5.10.1 – App‑controlled maze game
Goal: Move a player dot around a simple maze using app commands; block movement at borders.
Blocks used:
- BLE callback: Receive FWD/BACK/LEFT/RIGHT/STOP.
- OLED draw: Walls and player dot.
Block sequence:
- Init OLED and draw maze borders.
- Track player (x,y).
- On app command, move if inside bounds.
- Redraw and print position.
MicroPython code:
import machine # Import machine to access I2C and pins
import oled128x64 # Import OLED driver for SSD1306 128x64
import ble_central # Import BLE central role to scan/connect
import ble_peripheral # Import BLE peripheral role to advertise a name
import ble_handle # Import BLE handler to receive messages
i2c = machine.SoftI2C(scl=machine.Pin(22), sda=machine.Pin(21), freq=100000) # Setup I2C bus on pins 22/21
oled = oled128x64.OLED(i2c, address=0x3c, font_address=0x3A0000, types=0) # Initialize OLED at address 0x3C
print("Microproject 5.10.1: OLED ready") # Status: OLED initialized
ble_c = ble_central.BLESimpleCentral() # Create BLE central object
ble_p = ble_peripheral.BLESimplePeripheral('Clu-Bots') # Create BLE peripheral named 'Clu-Bots'
ble_c.scan() # Start scanning for peripherals
ble_c.connect(name='Clu-Bots') # Connect to 'Clu-Bots' by name
print("BLE connected") # Status: BLE connection established
player_x, player_y = 6, 6 # Set initial player position inside the screen
print("Player start:", player_x, player_y) # Show starting position
walls = [(0,0,127,0), (0,63,127,63), (0,0,0,63), (127,0,127,63)] # Define border walls (rectangle)
print("Walls defined:", len(walls)) # Confirm how many walls exist
def draw_maze(): # Function to draw the maze and player
oled.clear() # Clear the display before drawing
for x1, y1, x2, y2 in walls: # Loop through each wall segment
oled.line(x1, y1, x2, y2, 1) # Draw each wall line
oled.shows('MAZE', x=0, y=0, size=1, space=0, center=False) # Draw title text
oled.point(player_x, player_y) # Draw player as a single pixel
oled.show() # Refresh the screen so drawings appear
print("Maze drawn; player at:", player_x, player_y) # Confirm on serial
def in_bounds(nx, ny): # Function to check screen bounds
if nx < 1 or nx > 126 or ny < 1 or ny > 62: # Check against border walls
print("Blocked: out of bounds") # Explain why movement fails
return False # Movement not allowed
return True # Movement allowed
def move(cmd): # Function to handle movement commands
global player_x, player_y # Use global player coordinates
nx, ny = player_x, player_y # Start with current position
if cmd == "FWD": # If moving forward
ny = player_y - 2 # Move up by 2 pixels
elif cmd == "BACK": # If moving backward
ny = player_y + 2 # Move down by 2 pixels
elif cmd == "LEFT": # If moving left
nx = player_x - 2 # Move left by 2 pixels
elif cmd == "RIGHT": # If moving right
nx = player_x + 2 # Move right by 2 pixels
elif cmd == "STOP": # If stop command
print("Stopped (no movement)") # Acknowledge stop command
return # Exit with no movement
if in_bounds(nx, ny): # Check if new position is inside bounds
player_x, player_y = nx, ny # Apply movement
print("Moved to:", player_x, player_y) # Print new position
draw_maze() # Redraw maze with updated player position
else: # If movement is blocked
print("No move: blocked") # Explain why movement did not happen
def handle_method(key1, key2, key3, keyx): # BLE receive callback for movement
msg = str(key1) # Convert incoming payload to text
print("APP->R32:", msg) # Show the received command
move(msg) # Try to move based on the command
handle = ble_handle.Handle() # Create BLE handler object
handle.recv(handle_method) # Attach the callback to receive BLE messages
draw_maze() # Draw the initial maze and player
Reflection: Fast draw‑move‑redraw makes the game feel responsive and fun.
Challenge:
- Easy: Change step size from 2 to 3 pixels for faster movement.
- Harder: Add an inner wall line and prevent passing through it by adding a simple check.
Microproject 5.10.2 – Reaction game with sensors
Goal: Measure reaction time using two app events: START then STOP; print milliseconds.
Blocks used:
- Timing: Use ticks_ms and ticks_diff.
- Serial println: Print “REACTION(ms):…”.
Block sequence:
- Wait for “START”.
- Record start time.
- Wait for “STOP”.
- Compute and print reaction ms.
MicroPython code:
import time # Import time to access tick functions
waiting = True # Create a flag to indicate waiting for START
start_ms = 0 # Create a variable for the start timestamp
print("Microproject 5.10.2: Reaction game ready") # Status message
def handle_method(key1, key2, key3, keyx): # BLE callback to capture START/STOP
global waiting # Use global flag to manage game state
global start_ms # Use global timestamp to store START time
msg = str(key1) # Convert incoming payload to text
print("APP->R32:", msg) # Print the received command
if msg == "START": # If the START command arrives
start_ms = time.ticks_ms() # Record current ms as start time
waiting = False # Set flag to indicate we are timing now
print("REACTION:START") # Print status so user knows timing began
elif msg == "STOP": # If the STOP command arrives
if waiting: # If STOP comes without START first
print("REACTION:NO_START") # Print a helpful message
else: # If we have a valid START before
end_ms = time.ticks_ms() # Record current ms as end time
reaction = time.ticks_diff(end_ms, start_ms) # Compute ms difference
print("REACTION(ms):", reaction) # Print final reaction time
waiting = True # Reset flag to wait for next START
Reflection: Reaction games improve focus—try to beat your best time.
Challenge:
- Easy: Track “BEST:” by keeping the lowest reaction ms seen.
- Harder: Add a random delay before showing “START” in your app to prevent guessing.
Microproject 5.10.3 – Scoring and levels in the app
Goal: Keep score, lives, and level; level up every few points.
Blocks used:
- Variables: score, lives, level.
- Serial println: Print “SCORE”, “LIVES”, “LEVEL”.
Block sequence:
- Set score=0, lives=3, level=1.
- On “SCORE:+1” increase score and maybe level.
- On “MISS” lose a life; reset if lives=0.
MicroPython code:
score = 0 # Initialize score to zero
lives = 3 # Initialize lives to three
level = 1 # Initialize level to one
print("Microproject 5.10.3: SCORE:", score, "LIVES:", lives, "LEVEL:", level) # Show initial stats
def handle_method(key1, key2, key3, keyx): # BLE callback to update stats
global score # Use global score so changes persist
global lives # Use global lives so changes persist
global level # Use global level so changes persist
msg = str(key1) # Convert payload to text
print("APP->R32:", msg) # Print the command received
if msg == "SCORE:+1": # If player scores
score += 1 # Add one point to score
print("SCORE:", score) # Print updated score
if score % 5 == 0: # If score is a multiple of 5
level += 1 # Level up by one
print("LEVEL:", level) # Print updated level
elif msg == "MISS": # If player misses
lives -= 1 # Subtract one life
print("LIVES:", lives) # Print remaining lives
if lives <= 0: # If no lives remain
print("GAME:RESET") # Print that game resets
score, lives, level = 0, 3, 1 # Reset stats to starting values
print("SCORE:", score, "LIVES:", lives, "LEVEL:", level) # Print reset stats
Reflection: Levels make progress feel exciting—set fair rules and celebrate wins.
Challenge:
- Easy: Level up every 3 points instead of 5.
- Harder: Add “BONUS:+5” that increases score by 5 instantly.
Microproject 5.10.4 – Tactile and visual feedback
Goal: Use LED and buzzer for feedback; show “WIN!” or “MISS!” on OLED.
Blocks used:
- Digital output: LED on Pin 13, buzzer on Pin 23.
- OLED shows string: Draw quick messages.
Block sequence:
- On win → LED ON briefly + buzzer beep + “WIN!”.
- On miss → LED OFF + longer beep + “MISS!”.
- Keep messages short and readable.
MicroPython code:
import machine # Import machine to access pins
import oled128x64 # Import OLED driver for SSD1306 128x64
led = machine.Pin(13, machine.Pin.OUT) # Create LED output on Pin 13
buzzer = machine.Pin(23, machine.Pin.OUT) # Create buzzer output on Pin 23
i2c = machine.SoftI2C(scl=machine.Pin(22), sda=machine.Pin(21), freq=100000) # Setup I2C on pins 22/21
oled = oled128x64.OLED(i2c, address=0x3c, font_address=0x3A0000, types=0) # Initialize OLED display
print("Microproject 5.10.4: LED/Buzzer/OLED ready") # Status: outputs ready
def feedback_win(): # Function to show win feedback
led.value(1) # Turn LED ON to celebrate
buzzer.value(1) # Turn buzzer ON for a short beep
oled.clear() # Clear OLED before drawing text
oled.shows('WIN!', x=40, y=20, size=2, space=0, center=False) # Draw big WIN text
oled.show() # Refresh the display so text appears
print("Feedback: WIN!") # Print status for the app
buzzer.value(0) # Turn buzzer OFF after beep
led.value(0) # Turn LED OFF to finish
def feedback_miss(): # Function to show miss feedback
led.value(0) # Ensure LED is OFF on miss
buzzer.value(1) # Turn buzzer ON for a longer beep
oled.clear() # Clear OLED before drawing text
oled.shows('MISS!', x=35, y=20, size=2, space=0, center=False) # Draw big MISS text
oled.show() # Refresh the display so text appears
print("Feedback: MISS!") # Print status for the app
buzzer.value(0) # Turn buzzer OFF after beep
Reflection: Instant light/sound makes your game feel alive—players know results right away.
Challenge:
- Easy: Keep “WIN!” on screen for 500 ms with a delay in your app flow.
- Harder: Draw a star for wins and a cross for misses using lines and points.
Microproject 5.10.5 – Turn‑based multiplayer
Goal: Alternate turns between Player A and Player B; add points to the current player.
Blocks used:
- Variables: turn, scoreA, scoreB.
- Serial println: Print “TURN:A/B”, “SCORE_A”, “SCORE_B”.
Block sequence:
- Start with turn=”A”, scoreA=0, scoreB=0.
- On “NEXT” switch turn.
- On “SCORE:+1” add to the current player.
MicroPython code:
turn = "A" # Initialize turn to Player A
scoreA, scoreB = 0, 0 # Initialize scores for Player A and B
print("Microproject 5.10.5: Turn=A, A=0, B=0") # Status: multiplayer ready
def handle_method(key1, key2, key3, keyx): # BLE callback for turn-based play
global turn # Use global turn to update between callbacks
global scoreA # Use global scoreA to persist changes
global scoreB # Use global scoreB to persist changes
msg = str(key1) # Convert payload to text
print("APP->R32:", msg) # Print received command
if msg == "NEXT": # If the NEXT turn command arrives
turn = "B" if turn == "A" else "A" # Toggle turn between A and B
print("TURN:", turn) # Print current player's turn
elif msg == "SCORE:+1": # If a point is scored
if turn == "A": # If it's Player A's turn
scoreA += 1 # Add one point to A
print("SCORE_A:", scoreA) # Print A's score
else: # If it's Player B's turn
scoreB += 1 # Add one point to B
print("SCORE_B:", scoreB) # Print B's score
Reflection: Fair turns keep the game friendly—everyone gets a chance to shine.
Challenge:
- Easy: Show a big “A” or “B” on the OLED to mark the turn.
- Harder: Add a “WINNER?” command that prints who leads right now.
Main project – Skill game with app
Blocks steps (with glossary)
- BLE init + callback: Receive app commands for movement, scoring, reaction, and turns.
- OLED: Draw maze borders and the player dot plus small stats.
- LED/Buzzer: Provide immediate win/miss feedback.
- Score/levels: Track score, lives, and level on serial for the app.
- Multiplayer: Alternate turns and track separate player scores.
Block sequence:
- Init BLE and attach callbacks.
- Setup OLED, LED, buzzer, and maze data.
- Move player on commands and check bounds.
- Run reaction game and scoring rules with feedback.
- Alternate turns and print scoreboard.
MicroPython code (mirroring blocks)
# Project 5.10 – Skill Game with App
import machine # Import machine for pins and I2C hardware
import oled128x64 # Import OLED driver for SSD1306 128x64
import ble_central # Import BLE central role
import ble_peripheral # Import BLE peripheral role
import ble_handle # Import BLE handler for callbacks
import time # Import time for reaction timing and delays
# Setup OLED hardware
i2c = machine.SoftI2C(scl=machine.Pin(22), sda=machine.Pin(21), freq=100000) # I2C bus on pins 22/21
oled = oled128x64.OLED(i2c, address=0x3c, font_address=0x3A0000, types=0) # OLED object at address 0x3C
print("OLED ready") # Confirm OLED setup
# Setup LED and buzzer on outputs
led = machine.Pin(13, machine.Pin.OUT) # LED output on Pin 13
buzzer = machine.Pin(23, machine.Pin.OUT) # Buzzer output on Pin 23
print("LED/Buzzer ready") # Confirm outputs
# Setup BLE connection
ble_c = ble_central.BLESimpleCentral() # BLE central instance
ble_p = ble_peripheral.BLESimplePeripheral('Clu-Bots') # BLE peripheral named 'Clu-Bots'
ble_c.scan() # Scan for BLE peripheral
ble_c.connect(name='Clu-Bots') # Connect to peripheral by name
print("BLE connected") # Confirm BLE connection
# Maze state and player position
player_x, player_y = 6, 6 # Player position inside bounds
walls = [(0,0,127,0), (0,63,127,63), (0,0,0,63), (127,0,127,63)] # Border walls (rectangle)
print("Maze loaded") # Confirm maze
# Game stats
score, lives, level = 0, 3, 1 # Score, lives, level
turn = "A" # Current player's turn
scoreA, scoreB = 0, 0 # Multiplayer scores
print("Stats -> SCORE:", score, "LIVES:", lives, "LEVEL:", level, "TURN:", turn) # Print stats
# Reaction game flags
waiting = True # Waiting for START signal
start_ms = 0 # Holder for start timestamp
print("Reaction initialized") # Confirm reaction setup
def draw_maze(): # Draw maze and HUD
oled.clear() # Clear screen before drawing
for x1, y1, x2, y2 in walls: # Loop through all wall segments
oled.line(x1, y1, x2, y2, 1) # Draw each wall line
oled.point(player_x, player_y) # Draw player dot
hud = 'S:'+str(score)+' L:'+str(lives)+' Lv:'+str(level)+' T:'+turn # Compose HUD text
oled.shows(hud, x=0, y=54, size=1, space=0, center=False) # Write HUD line
oled.show() # Refresh display
print("HUD:", hud, "POS:", player_x, player_y) # Print HUD and position
def in_bounds(nx, ny): # Bounds check for movement
if nx < 1 or nx > 126 or ny < 1 or ny > 62: # Limit inside border
print("Blocked: bounds") # Explain blocking
return False # Deny movement
return True # Allow movement
def move(cmd): # Movement handler
global player_x, player_y # Use global player position
step = 2 # Movement step in pixels
nx, ny = player_x, player_y # Create candidate position
if cmd == "FWD": # Forward (up)
ny -= step # Move up
elif cmd == "BACK": # Back (down)
ny += step # Move down
elif cmd == "LEFT": # Left
nx -= step # Move left
elif cmd == "RIGHT": # Right
nx += step # Move right
elif cmd == "STOP": # Stop command
print("STOP received") # Print stop
return # Exit with no change
if in_bounds(nx, ny): # Check bounds
player_x, player_y = nx, ny # Apply movement
print("Moved:", player_x, player_y) # Print new position
draw_maze() # Redraw maze
else: # Out of bounds
print("No move (blocked)") # Explain block
def feedback_win(): # Win feedback
led.value(1) # LED ON
buzzer.value(1) # Buzzer ON
oled.clear() # Clear display
oled.shows('WIN!', x=40, y=20, size=2, space=0, center=False) # Big WIN text
oled.show() # Refresh display
print("WIN!") # Print win
buzzer.value(0) # Buzzer OFF
led.value(0) # LED OFF
def feedback_miss(): # Miss feedback
led.value(0) # LED OFF
buzzer.value(1) # Buzzer ON
oled.clear() # Clear display
oled.shows('MISS!', x=35, y=20, size=2, space=0, center=False) # Big MISS text
oled.show() # Refresh
print("MISS!") # Print miss
buzzer.value(0) # Buzzer OFF
def add_score(points): # Score increment
global score, level # Use global stats
score += points # Increase score
print("SCORE:", score) # Print score
if score % 5 == 0: # Every 5 points level up
level += 1 # Increase level
print("LEVEL:", level) # Print level
draw_maze() # Redraw HUD
def miss_life(): # Life decrement
global lives, score, level # Use global stats
lives -= 1 # Decrease lives
print("LIVES:", lives) # Print lives
if lives <= 0: # If no lives left
print("GAME:RESET") # Announce reset
score, lives, level = 0, 3, 1 # Reset stats
print("SCORE:", score, "LIVES:", lives, "LEVEL:", level) # Print reset stats
draw_maze() # Redraw HUD
def handle_method(key1, key2, key3, keyx): # BLE receive callback
global waiting # Use reaction flag
global start_ms # Use reaction timestamp
global turn # Use current turn
global scoreA, scoreB # Use multiplayer scores
msg = str(key1) # Convert payload to text
print("APP->R32:", msg) # Print received command
# Movement commands
if msg in ("FWD", "BACK", "LEFT", "RIGHT", "STOP"): # Movement set
move(msg) # Execute movement
return # End this callback
# Reaction game
if msg == "START": # Start timing
start_ms = time.ticks_ms() # Record start ms
waiting = False # Mark timing started
print("REACTION:START") # Print status
return # End branch
if msg == "STOP": # Stop timing
if waiting: # If no start
print("REACTION:NO_START") # Explain issue
else: # Valid stop
end_ms = time.ticks_ms() # Record end ms
reaction = time.ticks_diff(end_ms, start_ms) # Compute reaction time
print("REACTION(ms):", reaction) # Print result
waiting = True # Reset to waiting
return # End branch
# Scoring commands
if msg == "SCORE:+1": # Add point
add_score(1) # Update score
feedback_win() # Show win feedback
if turn == "A": # If Player A
scoreA += 1 # Increase A score
print("SCORE_A:", scoreA) # Print A score
else: # If Player B
scoreB += 1 # Increase B score
print("SCORE_B:", scoreB) # Print B score
return # End branch
if msg == "MISS": # Miss event
miss_life() # Decrease life
feedback_miss() # Show miss feedback
return # End branch
# Turn switching
if msg == "NEXT": # Switch turn
turn = "B" if turn == "A" else "A" # Toggle turn
print("TURN:", turn) # Print current turn
draw_maze() # Update HUD with new turn
return # End branch
# Status query
if msg == "STATUS?": # Ask for status
print("STATUS:SCORE", score) # Print score
print("STATUS:LIVES", lives) # Print lives
print("STATUS:LEVEL", level) # Print level
print("STATUS:TURN", turn) # Print turn
print("STATUS:A", scoreA) # Print Player A score
print("STATUS:B", scoreB) # Print Player B score
return # End branch
# Unknown command
print("CMD:UNKNOWN", msg) # Report unrecognized command
handle = ble_handle.Handle() # Create BLE handler
handle.recv(handle_method) # Attach the BLE receive callback
draw_maze() # Draw initial maze and HUD
# Heartbeat loop to keep app synced
while True: # Main heartbeat loop
print("HB:SCORE", score) # Print heartbeat score
print("HB:LIVES", lives) # Print heartbeat lives
print("HB:LEVEL", level) # Print heartbeat level
print("HB:TURN", turn) # Print heartbeat turn
time.sleep(2) # Small delay for readability
External explanation
- What it teaches: You built a mini‑game controlled by an app: movement in a maze, reaction timing, scoring/levels, feedback, and multiplayer turns.
- Why it works: BLE callbacks turn app taps into instant actions; OLED shows the game state; LED/buzzer deliver quick feedback; variables track progress; short status prints keep the app display in sync.
- Key concept: “Input → update → feedback → score.”
Story time
Your board just became a pocket arcade. You tap—your player dashes. You react—WIN! The score climbs. Switch turns—your friend takes over. Tiny screen, big fun.
Debugging (2)
Debugging 5.10.1 – Difficulty not adjusted
Problem: Movement feels too easy or too hard at higher levels.
Clues: Player always moves by the same step, no matter the level.
Broken code:
step = 2 # Fixed step size that never changes
Fixed code:
step = 1 + (level // 2) # Increase step every 2 levels to scale difficulty
print("Step size:", step) # Print the current step for clarity
Why it works: Tying movement step to level makes the game ramp up in challenge naturally.
Avoid next time: Connect difficulty to variables (level, score) instead of fixed numbers.
Debugging 5.10.2 – Game elements are not responding
Problem: App sends commands but nothing changes on the screen.
Clues: Serial shows APP->R32, but the player doesn’t move or feedback doesn’t trigger.
Broken code:
def handle_method(...):
msg = str(key1) # Read message
# move(msg) # Forgot to call the movement function
Fixed code:
def handle_method(...):
msg = str(key1) # Read message
move(msg) # Call movement so position updates and OLED redraws
print("Action executed for:", msg) # Confirm execution on serial
Why it works: Executing the action updates the state and redraws visuals right away.
Avoid next time: Ensure every command path triggers the correct function.
Final checklist
- App movement changes the player’s position in the maze
- Reaction game prints milliseconds and resets correctly
- SCORE/LIVES/LEVEL update with clear prints
- LED/Buzzer/OLED feedback responds to WIN/MISS
- NEXT alternates turns and A/B scores update
Extras
- 🧠 Student tip: Add a “SAFE” mode that ignores movement for 3 seconds after MISS.
- 🧑🏫 Instructor tip: Have students write rules (scoring, levels, turns) before coding—clarity prevents bugs.
- 📖 Glossary:
- Reaction time: Milliseconds between “START” and “STOP.”
- Level: Difficulty stage that changes game behavior.
- Feedback: Immediate signals (LED/buzzer/OLED) that show success or failure.
- 💡 Mini tips:
- Keep status messages short and labeled for easy app parsing.
- Redraw the OLED only when something changes to stay responsive.
- Tune step size and lives to match players’ skill.