HUGE LED Pixel Panel

Updated 31 March 2022

I’ve always been fascinated by LED screens and seeing beautiful colours mix and merge to create vibrant displays. I also love games, so what better way to combine the two than a giant LED pixel matrix which can play games, display audio levels, really anything you can think of.

It uses a Raspberry Pi to controller strips of digital LEDs mounted inside a pixel grid with a diffuser mounted on top. It requires some basic woodworking skills, soldering, and knowledge of Raspberry Pi.

This is a fairly in-depth project build, and as such, the write-up is a bit lengthy, so I’ve broken each section up so you can jump to each stage of the project:

*As I create more games/programs to run, I’ll keep adding them to this project page for everyone to download/use.

Parts Used:

Tools and Other Materials Required:

  • Soldering iron
  • 3D printer (I used our Lulzbot Taz 6 printers)
  • Woodworking tools (drop saw, drill etc…)
  • High-strength epoxy (commonly known as the brand name Araldite)
  • Cyanoacrylate (super glue)
  • Timber screws of varying sizes

Designing and Printing the Grid

Modelling the baffle

The first step was to create a matrix for separating the pixels. Using non-diffused LEDs is possible for lighting panels, but only with extremely dense LED clusters to create a useable image. I wanted my panel to be big and retro, so some nice chunky pixels were in order. Having the luxury of multiple large format printers available meant that printing them was my first choice. Each pixel (including the pixel frame) is 33mm square, which works nicely with 30 LEDs/m strips. Each part of the frame is 2mm thick, so the pixel ends up only being 31mm in the lit area. Whilst creating mechanical clips for all of the panels would have been possible, I just wanted a simple way to join them and glue/screw together later.

There was no ‘one-size fits all’ tiling pattern available, so I created a couple of different patterns to allow them all to fit together nicely. It also means that someone can take the design and print out more panels to create a bigger matrix if they want.

To make things as easy as possible, I went with a 1m x 0.5m grid, which would allow for 1m strips to make up the rows.

Test baffle piece

(pictures of models)

Testing for light bleed

After printing a couple of the corner pieces in white, I realised that I should’ve checked for any light bleed between the pixels, so I got testing. It turns out that my baffle model worked well, and almost no light escaped on the top or bottom of the pixels, however, even with a solid 2mm thick wall, the white filament allowed too much light through and you can see a noticeable bleed between the pixels:

Light bleed comparison test

Using a black or grey filament eliminated this problem, so I went with grey as I had a bunch of it on hand. I also increased the baffle height to be 20mm instead of 15mm to allow for better dispersion of the light, creating a more even diffusion.

Adding screw tabs

After printing out all the baffles, I realised that I had forgotten to add some anchor points to bolt it into the frame. Not to worry, I just designed and printed some tabs which slotted perfectly between a pixel (on the bottom edge to avoid blocking any light). Some 2-part epoxy and they were rock solid.  The entire baffle gets epoxied to the backing frame as well, but having some hardware to help with that mounting is also good.

Constructing the Frame

Using miter joins for frame

Before I assembled the baffle as one whole piece, I wanted to make the frame first so I could ensure a snug fit. Using some 19mm x 42mm pine and some miter joins, I built a simple frame, and then fixed a piece of thick, marine ply to the back. Rather than have all of the wiring exposed on the back panel, I planned to mount a second piece of backing play on top of a small frame to cover the first piece. This can be used to hide any wiring, and also mount the power supplies and board.

Gluing baffle sections together

With my frame completed, I did a second test fit of all the baffle pieces and everything was looking good. After an afternoon and next morning of epoxying the entire baffle together, it became one whole piece which mounted perfectly into the frame with less than 1mm of wiggle room inside the frame. I used Araldite brand epoxy which worked well as it has a 5min set time, but does require 8 hours to fully cure. It is rated at 75KG when fully cured though, so that’s pretty awesome.

Completed and glued baffle grid

Screwing entire baffle into the frame

I used 6x M3 bolts to secure the baffle to the outer frame. This held it in enough to move it around and work on the wiring, without making it to permanent. I ended up removing these once everything was in place as they were no longer required. I then used black silicon and more epoxy to secure everything into place after the wiring was completed.

Screw tabs for baffle

Adding Backing Panel

The last step to complete the main structure of the display was to add a sheet of 12mm marine grade ply to the back, screwed into the frame, with holes drilled out to run power and data out to the main circuit.

Wiring and Circuitry

Wiring up the LEDs

To make the wiring as neat as possible, I arranged the direction of the grid in a zig-zag pattern starting with 0,0 in a corner. Whilst it makes the code handling a bit trickier, having the wiring as neat as possible was a higher priority, as things can always be modified in code.

Progress of wiring LED strips

Because Dotstar LEDs use SPI, Clock and Data lines are needed and it was the first time I’d wired up that many LEDs in series (15m all up!). After putting in all the SPI wiring, I daisy chained 5V to test the LEDs one by one. The only issue I encountered was a flickering, glitchy behavior on the last few rows of LEDs which I assumed was due to the way I was temporarily running power (it wasn’t, but more on that further on!).

Finished LED strip wiring

Using multiple power supplies

Something to note about this product is that it requires a fair amount of power. If every LED was lit up at 100% brightness it would draw just over 50 Amps! Fortunately, there are very few cases where you would ever need to draw that much current, however, I used 5 Adafruit 5V 10A power supplies to feed the beast along with some soft limiting in the code, just to allow for maximum fun.

Multiple power supplies mounted

With the Clock and Data lines tested and verified, and all of the LEDs successfully addressed (with the exception of the random glitch behavior), I wired up all 5 power supplies, one for every 3 strips which ensured adequate power for all of the LEDs. The grounds are all connected together, but each 5V line is separated.

Wiring up to Raspberry Pi GPIO

The digital wiring for the Pi is incredibly simple, all I needed to connect was the clock and data lines to the SPI CLK and MOSI pins on the Pi, as well as ground, and I used a spare spot on the power board for a USB port to power the Pi.

Raspberry Pi JST connector

Complete rear panel wiring and hanging

With the power supplies mounted, I installed a 6 space power board, ran all of the wires into looms through the panel, and also mounted two hardware strips to run heavy bolts with lock nuts to create a wire hanger for mounting.

Rear panel wiring

Locking nut hanging method

Cleaning Up the Grid and Mounting the Diffuser

Mounting paper vellum diffuser

LEDs output bright, focused light, and that’s great for some things, but not for illuminating a giant pixel. To soften the light, I used vellum paper (very similar to tracing paper at art stores) to diffuse each pixel and create a soft, uniform glow. One sheet wasn’t quite enough to get the desired effect, so I used two.

Diffusion layer comparison

I used some thin, double-sided foam tape on the frame to help mount the vellum paper, before stapling both sheets down around the edge and cutting them down to size.

Mounting acrylic panel

Whilst the vellum paper looked great, it’s fairly easy to tear, and almost impossible to get completely taut so it makes good contact with every part of the grid. So, to create a nice, polished surface, I used a sheet of 4.5mm acrylic, cut down to the size of the frame. I cut out 10 holes for mounting bolts using our new Trotec laser and then used those for guides to drill through the whole frame. I ended up removing the screws I used to mount the backing board to the frame, as they weren’t necessary with the new bolts, and because I had measured the holes for the bolts in the exact same place on the frame (oops!).

Acrylic sheet with no borders

The acrylic panel made it look amazing when lit, but the outer edge of the frame wasn’t as clean as I would’ve liked it, so I carefully used some gloss black spray paint to create a nice border.

Taping for acrylic border paint

The only thing I would do differently is to drill the holes for the bolts in the wood before mounting the vellum as when drilling it all together, fine wood chips became lodged in between the layers of vellum. But alas, the construction is finished!

Finished LED matrix construction


All of the scripts and info required to build your own LED matrix (with some alteration required if you use different dimensions or controllers etc...) can be found on the LED Pixel Panel Github repo.

As I mentioned earlier, there was a strange glitching artifact which occurred towards the end of the signal run. It turned out to be an issue with the line capacitance which played havoc with the high-speed SPI signals, so all that was needed was to decrease the SPI clock speed to 8MHz which is still very fast.

I've posted the scripts for the VU meter and Snake below, however, whilst those scripts will run standalone with the required python modules installed, the project is designed to run on startup, and uses Bluetooth Keyboard inputs to switch between the scripts that are running, which are taken care of by the and files.

Snake gameplay gifSnake

I based my code off a demo written by Paul Brown who published a great demonstration of his snake game which can be played on a Pimoroni Unicorn HAT. After picking through it line by line to understand the mechanics of it, I went to work modifying it to work with an 8Bitdo Bluetooth controller. Pygame actually made it easier than I anticipated as the 8Bitdo controllers have a mode to operate as a standard keyboard.

I tweaked it and added some different event handling and animations, but kept it on the Unicorn HAT form factor. I actually got started on this while I was waiting for my baffle prints to finish, so I didn’t have the full matrix to test it with.

Once I had the basic mechanics working as I wanted them to (I added some end game animations and event handling, plus integrated controller mapping and some other bits and pieces), I modified the code to work with the pixel matrix. It was actually fairly easy as the Unicorn HAT uses WS2812 pixels, which, whilst being functionally different to the APA102 LEDs I used, the library handlers are almost identical (the APA102s are much faster though as they use hardware SPI, not bit-banging protocol).

from dotstar import Adafruit_DotStar
from random import randint
import time, pygame

#button mapping
up = 46
down = 32
left = 18
right = 33

start = 24

sizeX = 30
sizeY = 15

NUMPIXELS = 2*(sizeX*sizeY)
order = 'gbr'


strip = Adafruit_DotStar(NUMPIXELS, 12000000, order=order)


#screen size, used for pygame input events and to display instructions 
#colours     r       g     b
BLACK =  (    0,     0,     0)
WHITE = ( 255, 255, 255)
GREEN = (     0, 255,     0)
RED =     ( 255,     0,      0)
BLUE = (     0,       0,  255)
YELLOW =(255, 255,    0)
PURPLE = (255,    0, 255)
CYAN =   (     0, 255, 255)

keyPressed = 0

#grid mapping handler
pixelGrid = [[0 for x in range(sizeX 1)] for y in range(sizeY 1)]

keyVal = 840
for j in range(sizeY):

    for i in range(sizeX):
	if(j % 2 == 0):
        	rawVal = keyVal   (i*2)
		rawVal = keyVal - (i*2)
        pixelGrid[j][i] = rawVal
    if(j % 2 == 0):
        keyVal = keyVal - 2
        keyVal = keyVal - 118


#snake class for player
class Snake():
    body_list = None #snake segment locations
    change_x = None #movement on x-axis
    change_y = None #movement on y-axis
    eaten = None #has the snake eaten some food?
    g_over = False #holds value for game over if snake goes off the screen or eats itself
    def __init__(self):
        self.body_list = [[14,10],[14,11]] #starting location
        self.change_x = 1
        self.change_y = 0
        self.eaten = False
    def update(self, food):
        #remove old segment
        old_segment = self.body_list.pop()
        self.eaten = False
        #find new segment
        x = self.body_list[0][0]   self.change_x
        y = self.body_list[0][1]   self.change_y
	if x == sizeX:
		x = 0
	if x < 0:
		x = sizeX-1
	if y == sizeY:
		y = 0
	if y < 0:
		y = sizeY-1
        segment = [x,y]
        self.body_list.insert(0, segment)
        #check for eaten food
        if segment[0] == food.x_pos and segment[1] == food.y_pos:
            self.eaten = True
	    global SPEED
	    SPEED  = 0.4
            strip.setPixelColor(pixelGrid[old_segment[1]][old_segment[0]] 1,BLACK[0],BLACK[1],BLACK[2])

        #prepare segments for display on unicorn hat, use try to prevent exception from crashing the game
        for segment in self.body_list:
                strip.setPixelColor(pixelGrid[segment[1]][segment[0]] 1,WHITE[0],WHITE[1],WHITE[2])
                self.g_over = True
    #movement controls
    def go_left(self):
        self.change_x = -1
        self.change_y = 0
    def go_right(self):
        self.change_x = 1
        self.change_y = 0
    def go_up(self):
        self.change_x = 0
        self.change_y = 1
    def go_down(self):
        self.change_x = 0
        self.change_y = -1
    #check for game over, returned to game class
    def game_over(self):
        #game over events
        if self.body_list[0] in self.body_list[1::] or self.g_over == True:
            return True
        #if self.body_list[0][0] > sizeX or self.body_list[0][0] < 0 or self.body_list[0][1] > sizeY or self.body_list[0][1] < 0:
            #return True
        return False

    def game_over_sequence(self):
        for segment in self.body_list:
            strip.setPixelColor(pixelGrid[segment[1]][segment[0]], 255,50,50)
            strip.setPixelColor(pixelGrid[segment[1]][segment[0]] 1, 255,50,50)
	game = Game()
#Class for food
class Food():
    eaten = None
    x_pos = None
    y_pos = None
    r = None
    g = None
    b = None
    def __init__(self):
        self.eaten = True #set to true so that it is reset at start

    def update(self, snake):
        if self.eaten:
            #inside checks to ensure food isn't draw in the same location as the snakes body
            inside = True
            while inside:
                self.x_pos = randint(0,(sizeX-1))
                self.y_pos = randint(0,(sizeY-1))
                if [self.x_pos, self.y_pos] in snake.body_list:
                    inside = True
		if self.x_pos == 0 and self.y_pos == 14:
		    inside = True
                    inside = False
            #give food a random colour from list of colours. List ensures visible strong colours
            colour = randint(0,len(COLOURS)-1)
            self.r = COLOURS[colour][0]
            self.g = COLOURS[colour][1]
            self.b = COLOURS[colour][2]
        #prepare for display on unicorn hat    
        strip.setPixelColor(pixelGrid[self.y_pos][self.x_pos] 1,self.r,self.g,self.b)
        self.eaten = False

#game class
class Game(object):
    snake = None
    food = None
    game_over = None
    start = False #used to start the game in the same conditions as game over
    def __init__(self):
        self.snake = Snake() = Food()
    def process_events(self):
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                return True #exits main game loop
            if event.type == pygame.KEYDOWN and event.key == pygame.K_o:
                #start or restart the game
		global SPEED
                self.start = False
            if event.type == pygame.KEYDOWN:
		if event.key == pygame.K_n:
		global keyPressed
                if event.key == pygame.K_e and self.snake.change_x != 1 and keyPressed == 0:
		    keyPressed = 1
                elif event.key == pygame.K_f and self.snake.change_x != -1 and keyPressed == 0:
		    keyPressed = 1
                elif event.key == pygame.K_c and self.snake.change_y != -1 and keyPressed == 0:
                     keyPressed = 1
                elif event.key == pygame.K_d and self.snake.change_y != 1 and keyPressed == 0:
		    keyPressed = 1
        return False #stay in main game loop

    def run_logic(self):
        #check for game over first
        self.game_over = self.snake.game_over()
        #only if it is not the first game and not game over update the food and snake
        if not self.game_over and not self.start:
   = self.snake.eaten

    def display_frame(self, screen):
        #screen used to display information to the player, also required to detect pygame events
        #Unicorn hat display is also handled here
        if self.game_over or self.start:
            strip.clear() # removes last snake image from unicorn hat
   # display the snake and food on the unicorn hat


#main game function and loop
def main():
    global keyPressed

    screen = pygame.display.set_mode(size)

    done = False
    clock = pygame.time.Clock()

    game = Game()
    while not done:
        	done = game.process_events()
		keyPressed = 0
	except KeyboardInterrupt:
		print("Ctrl-C Terminating...")

if __name__ == '__main__':

VU meter gifSpectrum Analyser

This was a fun script to write as it involved learning about some new concepts such as Fourier transforms and other cool maths, however, I left the heavy lifting to the brilliant minds who wrote modules such as ‘numpy’ (number-py). Using a USB microphone, I worked from the basic example shared on the fantastic Rotorton website and then modified it to suit my project.

import sys
import pyaudio
from struct import unpack
import numpy as np
from dotstar import Adafruit_DotStar
import os
import time

toggleDir = '/home/pi/SnakeMatrix/toggleYes'

ORDER = 'gbr'
strip = Adafruit_DotStar(NUMPIXELS, 8000000, order=ORDER)


#setup grid mapping for matrix display
sizeX = 30
sizeY = 15

pixelGrid = [[0 for x in range(sizeX)] for y in range(sizeY)]

keyVal = 840
for j in range(sizeY):

    for i in range(sizeX):
	if(j % 2 == 0):
        	rawVal = keyVal   (i*2)
		rawVal = keyVal - (i*2)
        pixelGrid[j][i] = rawVal

    if(j % 2 == 0):
        keyVal = keyVal - 2
        keyVal = keyVal - 118

colourGrad =   [0xff0000,0xff0034,0xff0069,0xff009e,0xff00d2,0xf600ff,0xc100ff,0x8c00ff,0x5800ff,0x2300ff,
matrix    = [0 for x in range(30)]
power     = []
weighting = [1,1,1,1,2,2,2,2,4,4,4,4,8,8,8,8,16,16,16,16,32,32,32,32,64,64,64,64,128,128] 

device = 0
yeti = 'Yeti Stereo Microphone'
usbMic = 'USB PnP Sound Device'

def list_devices():
    #microphone profiles
    global yeti
    global usbMic
    global device
    # List all audio input devices
    p = pyaudio.PyAudio()
    i = 0
    n = p.get_device_count()
    while i < n:
        dev = p.get_device_info_by_index(i)
        if dev['maxInputChannels'] > 0:
           print(str(i) '. ' dev['name'])
	if yeti in (dev['name']):
	   device = i
	   return yeti
	elif usbMic in (dev['name']):
	   device = i
	   return usbMic
        i  = 1

# Audio setup
no_channels = 1
sample_rate = 44100

# Chunk must be a multiple of 8
# NOTE: If chunk size is too small the program will crash
# with error message: [Errno Input overflowed]
chunk = 4096

micProfile = list_devices()

#frequency response scaling for each microphone

if micProfile == usbMic:
	senseVal = 20
	bassAdj = 1*senseVal
	midAdj = 1*senseVal
	trebleAdj = 1*senseVal
elif micProfile == yeti:
	senseVal = 10
	bassAdj = 1*senseVal
	midAdj = 1*senseVal
	trebleAdj = 1*senseVal

p = pyaudio.PyAudio()
stream = = pyaudio.paInt16,
                channels = no_channels,
                rate = sample_rate,
                input = True,
                frames_per_buffer = chunk,
                input_device_index = device)

# Return power array index corresponding to a particular frequency
def piff(val):
    return int(2*chunk*val/sample_rate)
def calculate_levels(data, chunk,sample_rate):
    global matrix
    # Convert raw data (ASCII string) to numpy array
    data = unpack("%dh"%(len(data)/2),data)
    data = np.array(data, dtype='h')
    # Apply FFT - real data
    # Remove last element in array to make it the same size as chunk
    # Find average 'amplitude' for specific frequency ranges in Hz
    power = np.abs(fourier)
    matrix[0]= int(np.mean(power[piff(0)   :piff(22):1]))*bassAdj
    matrix[1]= int(np.mean(power[piff(22)  :piff(28):1]))*bassAdj      
    matrix[2]= int(np.mean(power[piff(28)  :piff(36):1]))*bassAdj
    matrix[3]= int(np.mean(power[piff(36)  :piff(44):1]))*bassAdj
    matrix[4]= int(np.mean(power[piff(44)  :piff(56):1]))*bassAdj
    matrix[5]= int(np.mean(power[piff(56)  :piff(70):1]))*bassAdj
    matrix[6]= int(np.mean(power[piff(70)  :piff(89):1]))*midAdj
    matrix[7]= int(np.mean(power[piff(89)  :piff(112):1]))*midAdj
    matrix[8]= int(np.mean(power[piff(112) :piff(141):1]))*midAdj
    matrix[9]= int(np.mean(power[piff(140) :piff(180):1]))*midAdj
    matrix[10]= int(np.mean(power[piff(178):piff(224):1]))*midAdj
    matrix[11]= int(np.mean(power[piff(224):piff(282):1]))*midAdj
    matrix[12]= int(np.mean(power[piff(282):piff(355):1]))*midAdj
    matrix[13]= int(np.mean(power[piff(355):piff(447):1]))*midAdj
    matrix[14]= int(np.mean(power[piff(447):piff(562):1]))*midAdj
    matrix[15]= int(np.mean(power[piff(562):piff(622):1]))*midAdj   
    matrix[16]= int(np.mean(power[piff(622):piff(708):1]))*midAdj
    matrix[17]= int(np.mean(power[piff(708):piff(788):1]))*midAdj
    matrix[18]= int(np.mean(power[piff(788):piff(891):1]))*midAdj
    matrix[19]= int(np.mean(power[piff(891):piff(1122):1]))*midAdj
    matrix[20]= int(np.mean(power[piff(1122):piff(1413):1]))*midAdj
    matrix[21]= int(np.mean(power[piff(1413):piff(1778):1]))*midAdj
    matrix[22]= int(np.mean(power[piff(1778):piff(2239):1]))*midAdj
    matrix[23]= int(np.mean(power[piff(2239):piff(2818):1]))*trebleAdj
    matrix[24]= int(np.mean(power[piff(2818):piff(3548):1]))*trebleAdj
    matrix[25]= int(np.mean(power[piff(3548):piff(4467):1]))*trebleAdj
    matrix[26]= int(np.mean(power[piff(4467):piff(5623):1]))*trebleAdj
    matrix[27]= int(np.mean(power[piff(5623):piff(7079):1]))*trebleAdj
    matrix[28]= int(np.mean(power[piff(7079):piff(8913):1]))*trebleAdj
    matrix[29]= int(np.mean(power[piff(8913):piff(11220):1]))*trebleAdj

    # Tidy up column values for the LED matrix
    # Set floor at 0 and ceiling at 8 for LED matrix
    return matrix

# Main loop
while 1:
        # Get microphone data
       	data =
       	matrix=calculate_levels(data, chunk,sample_rate)
	lastColour = colourGrad[29]
	colourGrad.insert(0, lastColour)
       	for y in range (0,30):
       	    for x in range(0, matrix[y]):
       	        strip.setPixelColor(pixelGrid[x][y], colourGrad[y])
		strip.setPixelColor(pixelGrid[x][y] 1, colourGrad[y])
	while os.path.exists(toggleDir):
    except KeyboardInterrupt:
	if os.path.exists(toggleDir):
		print("path found")
        print("Ctrl-C Terminating...")
    except Exception, e:
	if os.path.exists(toggleDir):
		print("path found")
        print("ERROR Terminating...")

Build Your Own

As I mentioned at the start, hopefully, I'll have time to create some more games for this panel, however, with the matrix wrapper that I created, anyone can write scripts for it and simply pixels based on a set of cartesian coordinates where 0,0 is the bottom left corner (or you could modify it based on your layout).

I'd love to see a picture if you've made this project or come up with your own remix, so be sure to post some pictures!

Attachment - Project Files

Have a question? Ask the Author of this guide today!

Please enter minimum 20 characters

Your comment will be posted (automatically) on our Support Forum which is publicly accessible. Don't enter private information, such as your phone number.

Expect a quick reply during business hours, many of us check-in over the weekend as well.



Please continue if you would like to leave feedback for any of these topics:

  • Website features/issues
  • Content errors/improvements
  • Missing products/categories
  • Product assignments to categories
  • Search results relevance

For all other inquiries (orders status, stock levels, etc), please contact our support team for quick assistance.

Note: click continue and a draft email will be opened to edit. If you don't have an email client on your device, then send a message via the chat icon on the bottom left of our website.

Makers love reviews as much as you do, please follow this link to review the products you have purchased.