.. include:: /index.rst :start-after: start_hello_message :end-before: end_hello_message .. _py_whack_a_mole: 4.15 Whack-a-Mole Game ====================== **Introduction** In this lesson, you will build a **Whack-a-Mole game** using the Fusion HAT+. Four PWM-driven LEDs act as “moles,” fading smoothly in a 1-second cycle. Your goal is to hit the correct button when its corresponding LED lights up. A buzzer gives audio feedback: - **Correct hole → short high-pitch beep** - **Wrong hole → GAME OVER (low beep)** An SSD1306 OLED display shows your **current score** during the game and a **final score** on game over. This project combines PWM brightness shaping, button edge detection, timing loops, and a simple game state machine. ---------------------------------------------- **What You’ll Need** The following components are required for this project: .. list-table:: :widths: 30 20 :header-rows: 1 * - COMPONENT INTRODUCTION - PURCHASE LINK * - :ref:`cpn_wires` - |link_wires_buy| * - :ref:`cpn_led` - |link_led_buy| * - :ref:`cpn_button` - |link_button_buy| * - :ref:`cpn_buzzer` - |link_passive_buzzer_buy| * - :ref:`cpn_oled` - \- * - :ref:`cpn_fusion_hat` - \- * - Raspberry Pi - \- ---------------------------------------------- **Wiring Diagram** Refer to the wiring diagram for assembling the components: .. image:: img/fzz/4.15_whack_a_mole_bb.png :width: 80% :align: center ---------------------------------------------- **Setup Steps** #. Install the required libraries: .. raw:: html .. code-block:: shell sudo pip3 install adafruit-circuitpython-ssd1306 --break #. Run the game from the ``ai-lab-kit`` directory: .. raw:: html .. code-block:: shell cd ~/ai-lab-kit/python/ sudo python3 4.15_WhackAMole.py #. When the script runs: * One LED fades up/down (triangle wave). * Press **only the lit LED’s button** within the 1-second window: - Correct → score increases, next mole appears - Incorrect → buzzer plays error tone → **GAME OVER** * OLED shows only the current score (in-game) * OLED shows “GAME OVER” + final score upon losing * Press Ctrl+C to exit ---------------------------------------------- **Code** Here is the full Python script for the Whack-a-Mole game: .. raw:: html .. code-block:: python #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Whack-a-Mole using fusion_hat: - 4 PWM LEDs (smooth fade in/out; whole cycle = 1.0s) - 4 buttons via fusion_hat.Pin (PULL_DOWN; pressed=1) - Buzzer via fusion_hat.Buzzer(PWM(...)) for short feedback tone - SSD1306 OLED shows only the current hit count during game - If a non-lit button is pressed -> GAME OVER with final score """ import time import random from typing import List, Optional # ---- fusion_hat GPIO / PWM / Buzzer ---- from fusion_hat.pwm import PWM from fusion_hat.pin import Pin, Mode, Pull from fusion_hat.modules import Buzzer # ---- OLED ---- from PIL import Image, ImageDraw, ImageFont import adafruit_ssd1306 import board # ===================== Port map (EDIT HERE) ===================== # 4 PWM LED ports of Fusion HAT+ LED_PORTS = ['P0', 'P1', 'P2', 'P3'] # 4 button pins (BCM numbering). Example here uses 17/4/27/22. # Buttons are configured as PULL_DOWN, so pressed -> value==1 BTN_PINS = [17, 4, 27, 22] # Buzzer on a PWM-capable Fusion HAT+ port BUZZER_PORT = 'P4' # =============================================================== # ===================== OLED setup ===================== WIDTH, HEIGHT = 128, 64 i2c = board.I2C() oled = adafruit_ssd1306.SSD1306_I2C(WIDTH, HEIGHT, i2c, addr=0x3C) oled.fill(0) oled.show() image = Image.new("1", (WIDTH, HEIGHT)) draw = ImageDraw.Draw(image) font = ImageFont.load_default() def draw_score(score: int): """Render in-game screen: only the current score.""" draw.rectangle((0, 0, WIDTH, HEIGHT), outline=0, fill=0) msg = f"Score: {score}" tw, th = font.getbbox(msg)[2:] draw.text(((WIDTH - tw) // 2, (HEIGHT - th) // 2), msg, font=font, fill=255) oled.image(image) oled.show() def draw_game_over(score: int): """Render GAME OVER screen with final score.""" draw.rectangle((0, 0, WIDTH, HEIGHT), outline=0, fill=0) title = "GAME OVER" tw, th = font.getbbox(title)[2:] draw.text(((WIDTH - tw) // 2, 8), title, font=font, fill=255) line = f"Score: {score}" lw, lh = font.getbbox(line)[2:] draw.text(((WIDTH - lw) // 2, 8 + th + 8), line, font=font, fill=255) oled.image(image) oled.show() # ===================== Hardware objects ===================== # LEDs (PWM) leds = [PWM(p) for p in LED_PORTS] def led_set(idx: int, duty_pct: float): """Set LED brightness (0..100%).""" duty = max(0.0, min(100.0, float(duty_pct))) leds[idx].pulse_width_percent(duty) def all_leds_off(): for i in range(len(leds)): leds[i].pulse_width_percent(0) # Buttons (Pin, PULL_DOWN => pressed=1) buttons = [Pin(pin, mode=Mode.IN, pull=Pull.DOWN) for pin in BTN_PINS] def read_buttons() -> List[int]: """Read all buttons; return their current logic levels (0/1).""" return [b.value() if callable(getattr(b, "value", None)) else int(b.value) for b in buttons] # Buzzer (tonal) tb = Buzzer(PWM(BUZZER_PORT)) def beep_hit(): """Short bright tone for a correct hit.""" tb.play('A5', 0.08) # A5 ~ 880 Hz short chirp tb.off() def beep_miss(): """Lower/longer tone for a miss (game over).""" tb.play('E4', 0.25) # E4 ~ 330 Hz tb.off() # ===================== LED fade (1s total) ===================== def triangle01(u: float) -> float: """ Triangle wave in [0,1], rising 0->1 over first half, then 1->0. u in [0,1]. """ return 2*u if u < 0.5 else 2*(1.0 - u) def fade_led_with_button_check(active_idx: int, prev_states: List[int], duration: float = 1.0, step: float = 0.01, gamma: float = 1.8) -> (bool, Optional[List[int]]): """ Fade one LED (active_idx) with triangle brightness over 'duration' seconds. While fading, poll buttons at 'step' interval: - If matching button pressed (rising edge) -> immediately turn off LED, return (True, new_states). - If non-matching button pressed (rising edge) -> game over: return (False, new_states) and let caller handle. If no press -> return (None, new_states) to continue game. Returns: (hit, states) hit=True -> correct hit hit=False -> wrong button (game over) hit=None -> no button pressed in this cycle """ t0 = time.monotonic() states = prev_states[:] while True: t = time.monotonic() - t0 if t > duration: break # Triangle brightness 0..1 then 1..0 u = triangle01(t / duration) # Gamma correction for perceived linearity level = u ** gamma duty = 3 + 97 * level # keep a tiny floor so LED is visible at the start led_set(active_idx, duty) # Poll buttons and detect rising edges curr = read_buttons() for i, (p, c) in enumerate(zip(states, curr)): if p == 0 and c == 1: # rising edge = just pressed if i == active_idx: # Correct hit all_leds_off() beep_hit() return True, curr else: # Wrong hole -> game over all_leds_off() beep_miss() return False, curr states = curr time.sleep(step) # Finished fade with no press all_leds_off() return None, states # ===================== Game loop ===================== def pick_next(prev_idx: Optional[int]) -> int: """Pick a random index different from prev_idx.""" choices = list(range(4)) if prev_idx in choices: choices.remove(prev_idx) return random.choice(choices) def main(): random.seed(time.time()) score = 0 last_idx = None states = read_buttons() # baseline for edge detection draw_score(score) while True: # Choose which LED lights next idx = pick_next(last_idx) last_idx = idx # Run a 1.0s fade cycle, checking button presses hit, states = fade_led_with_button_check(idx, states, duration=1.0, step=0.01, gamma=1.8) if hit is False: # Wrong button pressed -> Game Over draw_game_over(score) break elif hit is True: # Correct hit -> score++ score += 1 draw_score(score) else: # No press during this mole -> no score change; continue pass # stop all outputs all_leds_off() if __name__ == "__main__": try: main() except KeyboardInterrupt: all_leds_off() oled.fill(0) oled.show() tb.off() print("\nExited.") ---------------------------------------------- **Understanding the Code** 1. **LED Fade Animation** Each LED performs a 1-second triangle-wave fade: - Brightness rises from 0 → 100% - Then falls from 100% → 0 - Gamma correction (``u ** gamma``) improves visual linearity .. code-block:: python def fade_led_with_button_check(active_idx: int, prev_states: List[int], duration: float = 1.0, step: float = 0.01, gamma: float = 1.8) -> (bool, Optional[List[int]]): ... 2. **Button Edge Detection** ``read_buttons()`` reads all four buttons. A **rising edge** (``previous=0, current=1``) means “button just pressed.” - If pressed button matches lit LED → **correct hit** - Else → **wrong hit → GAME OVER** .. code-block:: python def read_buttons() -> List[int]: """Read all buttons; return their current logic levels (0/1).""" return [b.value() if callable(getattr(b, "value", None)) else int(b.value) for b in buttons] 3. **Buzzer Feedback** - ``A5`` short chirp = success - ``E4`` longer tone = miss / game over .. code-block:: python # Buzzer (tonal) tb = Buzzer(PWM(BUZZER_PORT)) def beep_hit(): """Short bright tone for a correct hit.""" tb.play('A5', 0.08) # A5 ~ 880 Hz short chirp tb.off() def beep_miss(): """Lower/longer tone for a miss (game over).""" tb.play('E4', 0.25) # E4 ~ 330 Hz tb.off() 4. **Game Loop** - Randomly picks one of four LEDs (never the same twice in a row) - Runs a fade cycle, checking button presses - Updates score or ends the game .. code-block:: python while True: # Choose which LED lights next idx = pick_next(last_idx) last_idx = idx # Run a 1.0s fade cycle, checking button presses hit, states = fade_led_with_button_check(idx, states, duration=1.0, step=0.01, gamma=1.8) ... 5. **OLED Display** - Shows only the score during play - Shows GAME OVER + final score when game ends .. code-block:: python def draw_score(score: int): """Render in-game screen: only the current score.""" draw.rectangle((0, 0, WIDTH, HEIGHT), outline=0, fill=0) msg = f"Score: {score}" tw, th = font.getbbox(msg)[2:] draw.text(((WIDTH - tw) // 2, (HEIGHT - th) // 2), msg, font=font, fill=255) oled.image(image) oled.show() ... ---------------------------------------------- **Troubleshooting** - **Buttons do not register presses** - Ensure buttons are wired correctly using BCM pin numbering - Confirm ``PULL_DOWN`` is applied so pressed state = 1 - Check for correct ground wiring - **LEDs flicker instead of fading smoothly** - Reduce ``step`` time for smoother updates (e.g., 0.005) - Ensure PWM ports support proper duty cycle updates - **Game ends immediately without pressing anything** - A floating button pin may read as 1 - Ensure pull-down resistors are correctly configured - **Score does not increase** - Verify the LED index matches the button index - Ensure LED ports and button pins are in correct order ---------------------------------------------- **Try It Yourself** 1. **Variable Speed Mode** Gradually shorten the fade duration to make the game more challenging. 2. **Multi-LED Mode** Light up two LEDs at once—player must hit either one. 3. **Lives System** Allow 3 mistakes before game over. 4. **High-Score Storage** Save the highest score to a file and display it at startup. 5. **Animated Title Screen** Add a splash screen before the game starts. These ideas can turn Whack-a-Mole into a full arcade experience!