Lesson 20 — The 8×8 NeoPixel Grid
Estimated time: 90 minutes
- Learning Objectives
- New Hardware: WS2812B 8×8 NeoPixel Grid
- Concepts
- Guided Walkthrough
- Challenges
- Common Mistakes & Debugging
- Key Vocabulary
Learning Objectives
By the end of this lesson you will be able to:
- Wire the WS2812B 8×8 NeoPixel grid to the ESP32
- Convert row/column coordinates to a pixel index using
index = row * 8 + col - Use a framebuffer (2D list of colours) to prepare a frame before sending it
- Draw pixel art, borders, and shapes on the grid
- Perform batch updates to minimise flicker
New Hardware: WS2812B 8×8 NeoPixel Grid
Wiring
| Grid Pin | Connect To |
|---|---|
| 5V | 5V (not 3.3V!) |
| GND | GND |
| DIN | GPIO 6 |
The 8×8 grid needs 5V — it won’t work reliably from 3.3V. All 64 LEDs at full white can draw ~3.8A, so never set all pixels to (255, 255, 255) at full brightness. Keep colours dim (max 50–80 per channel) during development.
Concepts
64 Pixels, One Long Strip
The 8×8 grid is actually a single strip of 64 LEDs snaked back and forth. The neopixel module treats them as a flat list: np[0] through np[63].
To control pixel at row r, column c, you need to find its index in that flat list.
Row × 8 + Col
Row 0: index 0 1 2 3 4 5 6 7
Row 1: index 8 9 10 11 12 13 14 15
Row 2: index 16 17 18 19 20 21 22 23
...
Row 7: index 56 57 58 59 60 61 62 63
The formula: index = row * 8 + col
def pixel_index(row, col):
return row * 8 + col
So pixel_index(2, 3) = 2 * 8 + 3 = 19.
Setting Up the Grid
import machine, neopixel
NUM_PIXELS = 64
pin = machine.Pin(6, machine.Pin.OUT)
np = neopixel.NeoPixel(pin, NUM_PIXELS)
Set a single pixel and update:
np[pixel_index(3, 4)] = (0, 100, 0) # Dim green at row 3, col 4
np.write()
The Framebuffer Pattern
Instead of setting individual pixels and calling .write() for each one, build the entire frame in a 2D list first, then send it all at once. This avoids flicker.
# Framebuffer: 8 rows × 8 columns of (R, G, B) tuples
fb = [[(0, 0, 0)] * 8 for _ in range(8)]
# Draw into the framebuffer — no hardware update yet
fb[3][4] = (0, 100, 0)
fb[0][0] = (100, 0, 0)
# Send the whole frame at once
def render(fb, np):
for row in range(8):
for col in range(8):
np[row * 8 + col] = fb[row][col]
np.write()
render(fb, np)
Why framebuffer?
- Pixels are updated instantly and simultaneously
- No partially-lit frames visible to the eye
- Easy to reason about — the 2D list mirrors the physical grid
Clearing the Grid
def clear(fb):
for row in range(8):
for col in range(8):
fb[row][col] = (0, 0, 0)
Drawing Helpers
def set_pixel(fb, row, col, colour):
"""Set one pixel (bounds-checked)."""
if 0 <= row < 8 and 0 <= col < 8:
fb[row][col] = colour
def draw_border(fb, colour):
"""Draw a 1-pixel border around the edge."""
for c in range(8):
set_pixel(fb, 0, c, colour) # Top row
set_pixel(fb, 7, c, colour) # Bottom row
for r in range(8):
set_pixel(fb, r, 0, colour) # Left column
set_pixel(fb, r, 7, colour) # Right column
def draw_rect(fb, row, col, height, width, colour):
"""Fill a rectangle."""
for r in range(row, row + height):
for c in range(col, col + width):
set_pixel(fb, r, c, colour)
Guided Walkthrough
Step 1: Basic Setup and Single Pixels
import machine, neopixel, time
NUM_PIXELS = 64
pin = machine.Pin(6, machine.Pin.OUT)
np = neopixel.NeoPixel(pin, NUM_PIXELS)
def pixel_index(row, col):
return row * 8 + col
# Clear all pixels
np.fill((0, 0, 0))
np.write()
time.sleep(0.5)
# Light up the four corners
corners = [(0,0), (0,7), (7,0), (7,7)]
for (r, c) in corners:
np[pixel_index(r, c)] = (80, 0, 80) # Purple
np.write()
time.sleep(2)
# Light up the centre 2×2
for r in range(3, 5):
for c in range(3, 5):
np[pixel_index(r, c)] = (0, 80, 0) # Green
np.write()
time.sleep(2)
np.fill((0, 0, 0))
np.write()
Step 2: Framebuffer Setup
import machine, neopixel, time
pin = machine.Pin(6, machine.Pin.OUT)
np = neopixel.NeoPixel(pin, 64)
# --- Framebuffer helpers ---
def make_fb():
return [[(0, 0, 0)] * 8 for _ in range(8)]
def render(fb):
for row in range(8):
for col in range(8):
np[row * 8 + col] = fb[row][col]
np.write()
def set_pixel(fb, row, col, colour):
if 0 <= row < 8 and 0 <= col < 8:
fb[row][col] = colour
def clear(fb):
for row in range(8):
for col in range(8):
fb[row][col] = (0, 0, 0)
# --- Draw something ---
fb = make_fb()
# Diagonal stripe
for i in range(8):
set_pixel(fb, i, i, (80, 80, 0)) # Yellow diagonal
render(fb)
time.sleep(2)
clear(fb)
render(fb)
Step 3: Border and Rectangle
def draw_border(fb, colour):
for c in range(8):
set_pixel(fb, 0, c, colour)
set_pixel(fb, 7, c, colour)
for r in range(1, 7):
set_pixel(fb, r, 0, colour)
set_pixel(fb, r, 7, colour)
def fill_rect(fb, row, col, height, width, colour):
for r in range(row, row + height):
for c in range(col, col + width):
set_pixel(fb, r, c, colour)
# Demo
fb = make_fb()
draw_border(fb, (0, 50, 100)) # Blue border
fill_rect(fb, 2, 2, 4, 4, (80, 0, 0)) # Red inner square
render(fb)
time.sleep(3)
clear(fb)
render(fb)
Step 4: Pixel Art from a Bitmap
A bitmap is a 2D list of 0s and 1s (or 0s and colours). Map them to actual RGB values when rendering:
# Smiley face bitmap (1 = on, 0 = off)
SMILEY = [
[0, 0, 1, 1, 1, 1, 0, 0],
[0, 1, 0, 0, 0, 0, 1, 0],
[1, 0, 1, 0, 0, 1, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 1, 0, 0, 1, 0, 1],
[1, 0, 0, 1, 1, 0, 0, 1],
[0, 1, 0, 0, 0, 0, 1, 0],
[0, 0, 1, 1, 1, 1, 0, 0],
]
def draw_bitmap(fb, bitmap, colour):
for row in range(8):
for col in range(8):
if bitmap[row][col] == 1:
fb[row][col] = colour
else:
fb[row][col] = (0, 0, 0)
fb = make_fb()
draw_bitmap(fb, SMILEY, (80, 80, 0)) # Yellow smiley
render(fb)
time.sleep(3)
clear(fb)
render(fb)
Step 5: Colour Wash
Cycle through all pixels with a colour sweep:
import time
COLOURS = [
(60, 0, 0), # Red
(0, 60, 0), # Green
(0, 0, 60), # Blue
(60, 60, 0), # Yellow
(0, 60, 60), # Cyan
(60, 0, 60), # Magenta
]
fb = make_fb()
for colour in COLOURS:
for row in range(8):
for col in range(8):
fb[row][col] = colour
render(fb)
time.sleep(0.05)
time.sleep(0.3)
clear(fb)
render(fb)
Challenges
⭐ Core
Draw a cross (plus sign) on the 8×8 grid: the middle row (row 3 or 4) and middle column (col 3 or 4) should be lit. Choose your own colour. Use the framebuffer pattern.
⭐⭐ Extension
Create a traffic light display. Use the top section of the grid for red, middle for amber, and bottom for green. Cycle through red (3s) → red+amber (1s) → green (3s) → amber (1s) → red (3s), repeating 3 full cycles. Each colour should fill a rough 2-row rectangle in the appropriate area.
⭐⭐⭐ Stretch
Display the digits 0–9 as pixel art on the 8×8 grid, one at a time, cycling through them with a 1-second delay. Define each digit as an 8×8 bitmap (1=on, 0=off). Use a list of bitmaps: DIGITS = [ZERO, ONE, TWO, ...]. You only need to define 3 or more digits to count for full marks.
Common Mistakes & Debugging
Grid doesn’t light up / wrong pixels Check that you’re using GPIO 6 (not GPIO 48 — that’s the single built-in pixel). Confirm the DIN wire is connected to data-in (not data-out) on the grid.
np[64] — IndexError The grid has indices 0–63. row * 8 + col with row=7, col=7 gives 63 — the maximum. Any row or col outside 0–7 goes out of bounds. Always use set_pixel() with bounds checking.
Flickering display You’re probably calling np.write() inside your inner loop. Call it once per frame, after building the entire framebuffer.
Grid too bright / colours washed out Keep individual channel values ≤ 80 for comfortable viewing. Full white (255, 255, 255) on all 64 pixels draws ~3.8A and can brownout your USB power.
Row and column swapped index = row * 8 + col — row first, column second. If your art is rotated 90°, you have them backwards.
Key Vocabulary
| Term | Definition |
|---|---|
| WS2812B | The LED driver chip inside each NeoPixel — controls colour from a single data wire |
| pixel index | The flat position of a pixel in the NeoPixel strip: row * 8 + col |
| framebuffer | A 2D array holding the colour of every pixel before sending to hardware |
| render | Copy the framebuffer to the NeoPixel strip and call .write() |
| bitmap | A 2D array of 0s and 1s used to define a shape or character |
| batch update | Setting all pixels then writing once — prevents partial frames from showing |