Move Characters on LCD Screens with CircuitPython

Quick Summary
Learn how to move custom characters on a 16x2 LCD display using CircuitPython, button input, and Metro RP2040. Build your own mini LCD game today.
In This Article
Move Characters on an LCD Screen with CircuitPython
There's a particular kind of joy that hits you when a tiny pixel creature you built from scratch slides across a two-line LCD screen in response to your thumb on a button. It's low-resolution, it's delightfully primitive, and honestly? It feels like magic. I've been tinkering with CircuitPython for a while now, and the moment I got a custom character moving around a 16x2 LCD display — dodging invisible walls, respecting boundaries, responding to input — I caught myself grinning like I'd just shipped a AAA game. Moving characters on an LCD screen with CircuitPython is one of those projects that teaches you far more than it looks like it will, and I want to walk you through exactly how it works, why it matters, and where you can take it next.
What You Actually Need to Get Started
Before we dive into the logic, let's talk hardware. The setup that makes this all sing is a Metro RP2040 paired with an LCD character backpack shield, topped with a 16x2 character LCD display. That's sixteen columns and two rows — 32 total character positions, each one a 5x8 pixel grid of possibility.
The LCD character backpack shield is the unsung hero here. It handles the I2C communication between your microcontroller and the display, which means you're not wrestling with a rats' nest of wires. The shield also comes equipped with five directional buttons — left, right, up, down, and select — which become your game controller the moment you wire up the logic. Think of it as the world's most minimal arcade cabinet.
On the software side, you'll need CircuitPython installed on your Metro RP2040, along with the adafruit_character_lcd library. This library does the heavy lifting for display communication, cursor control, and backlight management. Get these pieces in place, and you're ready to write code that actually does something you can see and interact with.
Designing Your Custom Character for LCD Movement
Here's where things get genuinely creative. The 16x2 LCD display supports up to eight custom characters stored in its memory at any one time. Each character is defined as an 8x5 grid — eight rows of five pixels — and you build them by specifying which pixels are on and which are off. In CircuitPython, this looks like a list of binary row values that you pass into the library.
For a moving character, your design choices matter more than you might think. A spacecraft silhouette, a stick figure, a tiny monster — whatever you pick, it needs to read clearly at five pixels wide and eight pixels tall. Simplicity wins here. High contrast, clear outlines, and a shape that communicates direction or personality at a glance will make your project feel finished rather than rushed.
Once you've defined your character data, you register it with the display using the library's custom character method. That character then lives at index zero in the display's memory, and you reference it whenever you want to print it on screen. The fact that you can move this same character around — rather than printing new ones in different positions — is what makes animation feel fluid and intentional.
How the LCD Button Movement Logic Actually Works
This is the heart of the project, and it's more elegant than it first appears. You set two variables — column and row — to track your character's current position on the 16x2 grid. Initially, both are zero, placing your character in the top-left corner of the display.
A function called draw_player handles all rendering. Every time it's called, it clears the LCD, moves the cursor to the current column and row position, then prints your custom character at that location. Clearing and redrawing on each move is the trick that creates the illusion of movement — the character disappears from its old position and reappears at the new one, fast enough that it looks like it slid across the screen.
The main loop constantly checks the state of each directional button. If the left button is pressed and the current column is greater than zero — meaning the character isn't already at the left edge — the code subtracts one from the column value and sets a moved flag to True. That flag triggers a call to draw_player, which redraws the character at the updated position. The same pattern applies to right, up, and down, with boundary checks preventing the character from wandering off the edges of the display entirely.
Crucially, the code also waits for a button release before registering the next move. This prevents a single button press from zipping your character across the entire screen in a blur. It's a small detail, but it's what separates a responsive, game-like experience from a frustrating one.
Boundary Checking: Why It's More Important Than It Looks
Boundary checking might sound like a boring implementation detail, but it's actually one of the most important design decisions in this kind of project. Without it, your column or row variable could go negative or exceed the display dimensions, causing undefined behavior — garbage characters appearing, display glitches, or your program crashing entirely.
For a 16x2 display, the valid column range is 0 to 15, and the valid row range is 0 to 1. Your button logic needs to enforce these limits before updating any position variable. The pattern is clean: check the button, check the boundary, update if valid, draw if updated.
This same principle scales beautifully. Working with a larger display? Adjust your boundary values. Adding multiple characters or obstacles? Each one gets its own position variables and boundary rules. The logic pattern itself doesn't change — only the numbers do. That's good software thinking, and it's one of the quiet lessons this project teaches without announcing itself.
Expanding the Project: From Demo to Actual Game
Once your character moves reliably around the screen, the natural question is: what's next? The honest answer is that you're closer to a real game than you might think.
The most immediate addition is collision detection. If you add a second character — an enemy or an obstacle — at a fixed or randomly-moving position, you can check whether your player's coordinates match that position on every update. Match found? Trigger a game-over sequence, flash the backlight, display a score.
Shooting mechanics are another obvious extension. A projectile in this context is just a character (perhaps a simple dash or dot) that moves automatically in one direction each loop cycle, independent of button input. If its position matches an enemy's position, that's a hit. Suddenly you have a space shooter on a two-line LCD display, which is absurd and wonderful in equal measure.
For an endless runner style game, you'd scroll obstacles in from the right side of the display — spawning them at column 15 and moving them left by one position each loop tick — while the player character stays fixed on the left and must dodge by moving up or down between the two rows. It's a genuinely playable game format within the constraints of 16x2 characters.
The constraints themselves are part of the creative challenge. Working within tight limits forces clarity of design and teaches you to find richness in simplicity. That's a lesson that transfers well beyond microcontroller projects.
Tips for Cleaner Code and Better Performance
A few practical notes from building projects like this that will save you debugging time. First, always clear the display before redrawing — skipping this step leaves ghost characters behind as your sprite moves, which looks broken immediately. The clear operation is fast enough that it won't cause visible flicker in most cases.
Second, debounce your buttons properly. The wait-for-release approach used in this project is simple and effective, but for more complex projects you might want a timing-based debounce that ignores inputs within a few milliseconds of the last registered press. This prevents phantom inputs from noisy button contacts.
Free Weekly Newsletter
Enjoying this guide?
Get the best articles like this one delivered to your inbox every week. No spam.
Third, keep your draw_player function doing exactly one thing: drawing. Resist the temptation to embed game logic inside rendering functions. Separation of concerns makes your code easier to modify when you want to add features — and you will always want to add features.
Finally, consider using a small state machine to manage game states — idle, playing, game over. Even on a microcontroller project this small, having explicit states makes your main loop dramatically easier to read and extend.
Practical Conclusion
What I love most about this project is that it lives at the intersection of hardware and software in a way that stays tangible. You press a button, a pixel creature moves. The cause and effect chain is short, immediate, and satisfying. That feedback loop is what makes embedded programming so compelling compared to purely abstract software work.
Moving a character on an LCD screen with CircuitPython is a genuinely achievable weekend project, but it opens doors to game logic, input handling, custom graphics, and real-time interaction that are directly transferable to more complex builds. Start here. Get your little spacecraft sliding across those 32 character cells. Then start adding enemies.
The 16x2 LCD display has been around for decades, and people are still finding new ways to make it do interesting things. There's no shame in working with humble hardware. Sometimes the most constrained canvas produces the most creative work.
Frequently Asked Questions
What microcontroller do I need to move characters on an LCD with CircuitPython?
The Metro RP2040 is an excellent choice and pairs directly with the LCD character backpack shield. However, any CircuitPython-compatible board with I2C support will work — including Adafruit Feather boards and the Raspberry Pi Pico with CircuitPython installed. The key requirement is that your board supports CircuitPython and has accessible I2C pins.
How many custom characters can I display on a 16x2 LCD screen?
The 16x2 LCD display hardware supports up to eight custom characters stored in its memory simultaneously. These are indexed 0 through 7. You define each as an 8x5 pixel grid and register them with the display at startup. While only eight can exist in memory at once, you can redefine them during runtime if your project needs more variety.
Why does my character leave ghost images when it moves across the LCD?
This happens when you're not clearing the display before redrawing the character at its new position. Add an LCD clear call at the start of your draw function, before you update the cursor position and print the character. The display clear operation is fast and should not cause noticeable flicker on a standard 16x2 LCD panel.
Can I use this CircuitPython LCD movement technique to build a real game?
Absolutely — and it's more playable than you might expect. With boundary checking, a second character as an enemy, and a collision detection check in your main loop, you have the foundation of a working game. Endless runner, space shooter, and simple maze formats all map well onto the 16x2 display's two-row, sixteen-column grid. The constraints push you toward clean, decisive game design.
Frequently Asked Questions
What You Actually Need to Get Started
Before we dive into the logic, let's talk hardware. The setup that makes this all sing is a Metro RP2040 paired with an LCD character backpack shield, topped with a 16x2 character LCD display. That's sixteen columns and two rows — 32 total character positions, each one a 5x8 pixel grid of possibility.
The LCD character backpack shield is the unsung hero here. It handles the I2C communication between your microcontroller and the display, which means you're not wrestling with a rats' nest of wires. The shield also comes equipped with five directional buttons — left, right, up, down, and select — which become your game controller the moment you wire up the logic. Think of it as the world's most minimal arcade cabinet.
On the software side, you'll need CircuitPython installed on your Metro RP2040, along with the adafruit_character_lcd library. This library does the heavy lifting for display communication, cursor control, and backlight management. Get these pieces in place, and you're ready to write code that actually does something you can see and interact with.
Designing Your Custom Character for LCD Movement
Here's where things get genuinely creative. The 16x2 LCD display supports up to eight custom characters stored in its memory at any one time. Each character is defined as an 8x5 grid — eight rows of five pixels — and you build them by specifying which pixels are on and which are off. In CircuitPython, this looks like a list of binary row values that you pass into the library.
For a moving character, your design choices matter more than you might think. A spacecraft silhouette, a stick figure, a tiny monster — whatever you pick, it needs to read clearly at five pixels wide and eight pixels tall. Simplicity wins here. High contrast, clear outlines, and a shape that communicates direction or personality at a glance will make your project feel finished rather than rushed.
Once you've defined your character data, you register it with the display using the library's custom character method. That character then lives at index zero in the display's memory, and you reference it whenever you want to print it on screen. The fact that you can move this same character around — rather than printing new ones in different positions — is what makes animation feel fluid and intentional.
How the LCD Button Movement Logic Actually Works
This is the heart of the project, and it's more elegant than it first appears. You set two variables — column and row — to track your character's current position on the 16x2 grid. Initially, both are zero, placing your character in the top-left corner of the display.
A function called draw_player handles all rendering. Every time it's called, it clears the LCD, moves the cursor to the current column and row position, then prints your custom character at that location. Clearing and redrawing on each move is the trick that creates the illusion of movement — the character disappears from its old position and reappears at the new one, fast enough that it looks like it slid across the screen.
The main loop constantly checks the state of each directional button. If the left button is pressed and the current column is greater than zero — meaning the character isn't already at the left edge — the code subtracts one from the column value and sets a moved flag to True. That flag triggers a call to draw_player, which redraws the character at the updated position. The same pattern applies to right, up, and down, with boundary checks preventing the character from wandering off the edges of the display entirely.
Crucially, the code also waits for a button release before registering the next move. This prevents a single button press from zipping your character across the entire screen in a blur. It's a small detail, but it's what separates a responsive, game-like experience from a frustrating one.
Boundary Checking: Why It's More Important Than It Looks
Boundary checking might sound like a boring implementation detail, but it's actually one of the most important design decisions in this kind of project. Without it, your column or row variable could go negative or exceed the display dimensions, causing undefined behavior — garbage characters appearing, display glitches, or your program crashing entirely.
For a 16x2 display, the valid column range is 0 to 15, and the valid row range is 0 to 1. Your button logic needs to enforce these limits before updating any position variable. The pattern is clean: check the button, check the boundary, update if valid, draw if updated.
This same principle scales beautifully. Working with a larger display? Adjust your boundary values. Adding multiple characters or obstacles? Each one gets its own position variables and boundary rules. The logic pattern itself doesn't change — only the numbers do. That's good software thinking, and it's one of the quiet lessons this project teaches without announcing itself.
Expanding the Project: From Demo to Actual Game
Once your character moves reliably around the screen, the natural question is: what's next? The honest answer is that you're closer to a real game than you might think.
The most immediate addition is collision detection. If you add a second character — an enemy or an obstacle — at a fixed or randomly-moving position, you can check whether your player's coordinates match that position on every update. Match found? Trigger a game-over sequence, flash the backlight, display a score.
Shooting mechanics are another obvious extension. A projectile in this context is just a character (perhaps a simple dash or dot) that moves automatically in one direction each loop cycle, independent of button input. If its position matches an enemy's position, that's a hit. Suddenly you have a space shooter on a two-line LCD display, which is absurd and wonderful in equal measure.
For an endless runner style game, you'd scroll obstacles in from the right side of the display — spawning them at column 15 and moving them left by one position each loop tick — while the player character stays fixed on the left and must dodge by moving up or down between the two rows. It's a genuinely playable game format within the constraints of 16x2 characters.
The constraints themselves are part of the creative challenge. Working within tight limits forces clarity of design and teaches you to find richness in simplicity. That's a lesson that transfers well beyond microcontroller projects.
Tips for Cleaner Code and Better Performance
A few practical notes from building projects like this that will save you debugging time. First, always clear the display before redrawing — skipping this step leaves ghost characters behind as your sprite moves, which looks broken immediately. The clear operation is fast enough that it won't cause visible flicker in most cases.
Second, debounce your buttons properly. The wait-for-release approach used in this project is simple and effective, but for more complex projects you might want a timing-based debounce that ignores inputs within a few milliseconds of the last registered press. This prevents phantom inputs from noisy button contacts.
Third, keep your draw_player function doing exactly one thing: drawing. Resist the temptation to embed game logic inside rendering functions. Separation of concerns makes your code easier to modify when you want to add features — and you will always want to add features.
Finally, consider using a small state machine to manage game states — idle, playing, game over. Even on a microcontroller project this small, having explicit states makes your main loop dramatically easier to read and extend.
Practical Conclusion
What I love most about this project is that it lives at the intersection of hardware and software in a way that stays tangible. You press a button, a pixel creature moves. The cause and effect chain is short, immediate, and satisfying. That feedback loop is what makes embedded programming so compelling compared to purely abstract software work.
Moving a character on an LCD screen with CircuitPython is a genuinely achievable weekend project, but it opens doors to game logic, input handling, custom graphics, and real-time interaction that are directly transferable to more complex builds. Start here. Get your little spacecraft sliding across those 32 character cells. Then start adding enemies.
The 16x2 LCD display has been around for decades, and people are still finding new ways to make it do interesting things. There's no shame in working with humble hardware. Sometimes the most constrained canvas produces the most creative work.
Frequently Asked Questions
What microcontroller do I need to move characters on an LCD with CircuitPython?
The Metro RP2040 is an excellent choice and pairs directly with the LCD character backpack shield. However, any CircuitPython-compatible board with I2C support will work — including Adafruit Feather boards and the Raspberry Pi Pico with CircuitPython installed. The key requirement is that your board supports CircuitPython and has accessible I2C pins.
How many custom characters can I display on a 16x2 LCD screen?
The 16x2 LCD display hardware supports up to eight custom characters stored in its memory simultaneously. These are indexed 0 through 7. You define each as an 8x5 pixel grid and register them with the display at startup. While only eight can exist in memory at once, you can redefine them during runtime if your project needs more variety.
Why does my character leave ghost images when it moves across the LCD?
This happens when you're not clearing the display before redrawing the character at its new position. Add an LCD clear call at the start of your draw function, before you update the cursor position and print the character. The display clear operation is fast and should not cause noticeable flicker on a standard 16x2 LCD panel.
Can I use this CircuitPython LCD movement technique to build a real game?
Absolutely — and it's more playable than you might expect. With boundary checking, a second character as an enemy, and a collision detection check in your main loop, you have the foundation of a working game. Endless runner, space shooter, and simple maze formats all map well onto the 16x2 display's two-row, sixteen-column grid. The constraints push you toward clean, decisive game design.
About Zeebrain Editorial
Our editorial team is dedicated to providing clear, well-researched, and high-utility content for the modern digital landscape. We focus on accuracy, practicality, and insights that matter.
More from Travel & Places
Related Guides
Keep exploring this topic
Breadboard.ing: Fritzing for the Web Is Finally Here
Science & Tech · CircuitPython · Adafruit
DIY PCB Projects: Game Boy Hacks & Retro Maker Builds
Travel & Places · DIY Electronics · RP2040
Things to Do in Fort Lauderdale: Your Ultimate 2025 Travel Guide
Travel & Places
Top 5 Mistakes Tourists Make (Avoid These!)
Travel & Places
Explore More Categories
Keep browsing by topic and build depth around the subjects you care about most.


