Skip to content

Commit 5552b5c

Browse files
MatteoCnda1nedseb
andauthored
feat(ism330dl): Add maze game example with OLED and accelerometer. (#390)
* feat(ism330dl): Add maze game example with OLED and accelerometer. * fix(ism330dl): Fix recursive game loop and add cleanup in maze_game. Address review comments on #390: 1. Replace recursive run_game() self-call with an outer `while True` loop. On MicroPython's limited stack (8-16 KB), each completed game added a stack frame that was never freed — a few wins would crash with a RecursionError or silent stack overflow. 2. Add try/except/finally around the game loop: Ctrl+C now cleanly clears the display and powers off the IMU (same pattern as spirit_level.py) to avoid battery drain. 3. Add maze_game.py to the README examples table. --------- Co-authored-by: Sébastien NEDJAR <sebastien@nedjar.com>
1 parent 90862ab commit 5552b5c

2 files changed

Lines changed: 301 additions & 0 deletions

File tree

lib/ism330dl/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,5 +286,6 @@ The repository provides several example scripts:
286286
| `static_orientation.py` | Detect device orientation using the accelerometer |
287287
| `motion_orientation.py` | Detect rotation using the gyroscope |
288288
| `spirit_level.py` | Interactive digital bubble level using SSD1327 OLED|
289+
| `maze_game.py` | Tilt-controlled maze game with score on SSD1327 OLED|
289290

290291
---

lib/ism330dl/examples/maze_game.py

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
"""Maze game example using ISM330DL accelerometer and SSD1327 OLED.
2+
3+
Navigate through a randomly generated maze by tilting the STeaMi board.
4+
Your score is computed at the end based on how close you were to the
5+
optimal path, calculated with Dijkstra's algorithm.
6+
7+
Hardware:
8+
- ISM330DL accelerometer (tilt input)
9+
- SSD1327 128x128 OLED display (round)
10+
11+
Controls:
12+
- Tilt the board forward → move UP
13+
- Tilt the board backward → move DOWN
14+
- Tilt the board left → move LEFT
15+
- Tilt the board right → move RIGHT
16+
"""
17+
18+
import random
19+
from time import sleep_ms
20+
21+
import ssd1327
22+
from ism330dl import ISM330DL
23+
from machine import I2C, SPI, Pin
24+
from steami_screen import GRAY, LIGHT, WHITE, Screen, SSD1327Display
25+
26+
# =============================================================================
27+
# === Display setup ===========================================================
28+
# =============================================================================
29+
30+
spi = SPI(1)
31+
dc = Pin("DATA_COMMAND_DISPLAY")
32+
res = Pin("RST_DISPLAY")
33+
cs = Pin("CS_DISPLAY")
34+
display = SSD1327Display(ssd1327.WS_OLED_128X128_SPI(spi, dc, res, cs))
35+
screen = Screen(display)
36+
37+
# =============================================================================
38+
# === Accelerometer setup =====================================================
39+
# =============================================================================
40+
41+
i2c = I2C(1)
42+
imu = ISM330DL(i2c)
43+
44+
# =============================================================================
45+
# === Game constants ===========================================================
46+
# =============================================================================
47+
48+
MAZE_W = 11 # Must be odd
49+
MAZE_H = 11 # Must be odd
50+
START_ROW = 1
51+
START_COL = 1
52+
GOAL_ROW = MAZE_H - 2
53+
GOAL_COL = MAZE_W - 2
54+
MOVE_DELAY_MS = 200 # Delay between moves in milliseconds
55+
TILT_THRESHOLD = 0.3 # Minimum tilt in g to trigger a move
56+
57+
# Cell types
58+
WALL = 0
59+
PATH = 1
60+
61+
# Directions: (row_delta, col_delta)
62+
UP = (-1, 0)
63+
DOWN = (1, 0)
64+
LEFT = (0, -1)
65+
RIGHT = (0, 1)
66+
NONE = (0, 0)
67+
68+
# Display safe zone for round screen
69+
SAFE_X = 19
70+
SAFE_Y = 19
71+
SAFE_SIZE = 90
72+
CELL_SIZE = SAFE_SIZE // MAZE_W # 8px per cell
73+
74+
# =============================================================================
75+
# === Maze generation (Recursive Backtracker / DFS) ===========================
76+
# =============================================================================
77+
78+
79+
def generate_maze(width, height):
80+
"""Generate a perfect maze using Recursive Backtracker algorithm.
81+
82+
Returns a 2D list of WALL/PATH values.
83+
Width and height must be odd numbers.
84+
"""
85+
maze = [[WALL] * width for _ in range(height)]
86+
87+
def carve(row, col):
88+
maze[row][col] = PATH
89+
dirs = [(-2, 0), (2, 0), (0, -2), (0, 2)]
90+
# Shuffle directions
91+
for i in range(len(dirs) - 1, 0, -1):
92+
j = random.randint(0, i)
93+
dirs[i], dirs[j] = dirs[j], dirs[i]
94+
for dr, dc in dirs:
95+
nr, nc = row + dr, col + dc
96+
if 0 <= nr < height and 0 <= nc < width and maze[nr][nc] == WALL:
97+
maze[row + dr // 2][col + dc // 2] = PATH
98+
carve(nr, nc)
99+
100+
carve(1, 1)
101+
return maze
102+
103+
104+
# =============================================================================
105+
# === Dijkstra shortest path ==================================================
106+
# =============================================================================
107+
108+
109+
def dijkstra(maze, start_row, start_col, goal_row, goal_col):
110+
"""Find the shortest path length from start to goal in the maze.
111+
112+
Returns the number of steps in the optimal path, or -1 if not found.
113+
"""
114+
height = len(maze)
115+
width = len(maze[0])
116+
INF = 999999
117+
dist = [[INF] * width for _ in range(height)]
118+
dist[start_row][start_col] = 0
119+
queue = [(0, start_row, start_col)]
120+
121+
while queue:
122+
# Find minimum distance node
123+
min_idx = 0
124+
for i in range(1, len(queue)):
125+
if queue[i][0] < queue[min_idx][0]:
126+
min_idx = i
127+
cost, row, col = queue.pop(min_idx)
128+
129+
if row == goal_row and col == goal_col:
130+
return cost
131+
if cost > dist[row][col]:
132+
continue
133+
134+
for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
135+
nr, nc = row + dr, col + dc
136+
if 0 <= nr < height and 0 <= nc < width and maze[nr][nc] == PATH:
137+
new_cost = cost + 1
138+
if new_cost < dist[nr][nc]:
139+
dist[nr][nc] = new_cost
140+
queue.append((new_cost, nr, nc))
141+
142+
return -1
143+
144+
145+
def compute_score(player_steps, optimal_steps, max_score=1000):
146+
"""Compute player score based on path efficiency.
147+
148+
Returns a score between 0 and max_score.
149+
Perfect path = max_score. Score decreases as player takes more steps.
150+
"""
151+
if optimal_steps <= 0:
152+
return 0
153+
if player_steps <= optimal_steps:
154+
return max_score
155+
return max(0, int(max_score * optimal_steps / player_steps))
156+
157+
158+
# =============================================================================
159+
# === Accelerometer input =====================================================
160+
# =============================================================================
161+
162+
163+
def read_direction():
164+
ax, ay, _ = imu.acceleration_g()
165+
ax = -ax # Invert forward/backward axis
166+
abs_x = ax if ax >= 0 else -ax
167+
abs_y = ay if ay >= 0 else -ay
168+
169+
if abs_x < TILT_THRESHOLD and abs_y < TILT_THRESHOLD:
170+
return NONE
171+
if abs_x >= abs_y:
172+
return DOWN if ax > 0 else UP
173+
return RIGHT if ay > 0 else LEFT
174+
175+
176+
# =============================================================================
177+
# === Display helpers =========================================================
178+
# =============================================================================
179+
180+
181+
def cell_to_pixel(row, col):
182+
"""Convert maze cell coordinates to screen pixel coordinates."""
183+
return SAFE_X + col * CELL_SIZE, SAFE_Y + row * CELL_SIZE
184+
185+
186+
def draw_maze(maze):
187+
"""Draw all maze walls as filled rectangles."""
188+
for row in range(len(maze)):
189+
for col in range(len(maze[0])):
190+
x, y = cell_to_pixel(row, col)
191+
if maze[row][col] == WALL:
192+
screen.rect(x, y, CELL_SIZE, CELL_SIZE, LIGHT, fill=True)
193+
194+
195+
def draw_player(row, col):
196+
"""Draw player as a bright filled circle."""
197+
x, y = cell_to_pixel(row, col)
198+
cx = x + CELL_SIZE // 2
199+
cy = y + CELL_SIZE // 2
200+
screen.circle(cx, cy, CELL_SIZE // 2 - 1, WHITE, fill=True)
201+
202+
203+
def draw_goal(row, col):
204+
"""Draw goal marker as a gray filled circle."""
205+
x, y = cell_to_pixel(row, col)
206+
cx = x + CELL_SIZE // 2
207+
cy = y + CELL_SIZE // 2
208+
screen.circle(cx, cy, CELL_SIZE // 2 - 1, GRAY, fill=True)
209+
210+
211+
def render(maze, player_row, player_col, steps, optimal):
212+
"""Full frame render: maze, goal, player and HUD."""
213+
screen.clear()
214+
draw_maze(maze)
215+
draw_goal(GOAL_ROW, GOAL_COL)
216+
draw_player(player_row, player_col)
217+
screen.title(f"S:{steps} B:{optimal}")
218+
screen.show()
219+
220+
221+
# =============================================================================
222+
# === Screens =================================================================
223+
# =============================================================================
224+
225+
226+
def show_start_screen():
227+
"""Display start screen."""
228+
screen.clear()
229+
screen.title("MAZE GAME")
230+
screen.face("happy")
231+
screen.subtitle("Tilt to play!")
232+
screen.show()
233+
sleep_ms(2000)
234+
235+
236+
def show_win_screen(steps, optimal):
237+
"""Display win screen with score."""
238+
score = compute_score(steps, optimal)
239+
screen.clear()
240+
screen.title("YOU WIN!")
241+
screen.value(str(score), unit="pts")
242+
screen.subtitle(f"Steps: {steps}", f"Best: {optimal}")
243+
screen.show()
244+
sleep_ms(5000)
245+
246+
247+
# =============================================================================
248+
# === Main game loop ==========================================================
249+
# =============================================================================
250+
251+
252+
def run_game():
253+
"""Generate a new maze and run one full game round.
254+
255+
Returns when the player reaches the goal.
256+
"""
257+
maze = generate_maze(MAZE_W, MAZE_H)
258+
optimal = dijkstra(maze, START_ROW, START_COL, GOAL_ROW, GOAL_COL)
259+
260+
player_row = START_ROW
261+
player_col = START_COL
262+
steps = 0
263+
264+
show_start_screen()
265+
266+
while True:
267+
render(maze, player_row, player_col, steps, optimal)
268+
269+
direction = read_direction()
270+
if direction != NONE:
271+
dr, dc = direction
272+
nr = player_row + dr
273+
nc = player_col + dc
274+
if 0 <= nr < MAZE_H and 0 <= nc < MAZE_W and maze[nr][nc] == PATH:
275+
player_row = nr
276+
player_col = nc
277+
steps += 1
278+
279+
if player_row == GOAL_ROW and player_col == GOAL_COL:
280+
show_win_screen(steps, optimal)
281+
return
282+
283+
sleep_ms(MOVE_DELAY_MS)
284+
285+
286+
# =============================================================================
287+
# === Entry point =============================================================
288+
# =============================================================================
289+
290+
print("Maze game starting...")
291+
292+
try:
293+
while True:
294+
run_game()
295+
except KeyboardInterrupt:
296+
print("\nMaze game stopped.")
297+
finally:
298+
screen.clear()
299+
screen.show()
300+
imu.power_off()

0 commit comments

Comments
 (0)