Pong on Framebuffer
Time estimate: ~45 minutes Prerequisites: OLED Framebuffer Driver OR BUSE Framebuffer Driver
Learning Objectives
By the end of this tutorial you will be able to:
- Open and query a Linux framebuffer device using
ioctl - Map framebuffer memory into user space with
mmap() - Draw pixels on a 1bpp monochrome display
- Implement a game loop with non-blocking keyboard input
- Build an application that works on any monochrome framebuffer (OLED or BUSE)
User-space Framebuffer Applications
The framebuffer drivers you built (SSD1306 OLED or BUSE LED matrix) expose a standard /dev/fbN interface. Any application that writes pixels to this interface will work — regardless of the underlying hardware. This tutorial builds a Pong game in C that auto-detects the display resolution and adapts accordingly.
Course Source Repository
This tutorial references source files from the course repository. If you haven't cloned it yet on your Pi:
Source files for this tutorial are in ~/embedded-linux/apps/pong-fb/.
How Framebuffer Apps Work
A user-space framebuffer application follows this pattern:
- Open —
open("/dev/fb0", O_RDWR) - Query —
ioctl(FBIOGET_VSCREENINFO)returns width, height, bits-per-pixel - Map —
mmap()maps the framebuffer memory into your process - Draw — write pixel data directly into the mapped memory
- Sync —
msync()triggers the deferred I/O refresh (for drivers that use it) - Cleanup —
munmap(),close(), restore terminal
For 1bpp monochrome displays, each bit represents one pixel. There are two common layouts:
| Layout | line_length |
Byte contains | Used by |
|---|---|---|---|
| Linear row-major | width / 8 |
8 horizontal pixels (MSB = leftmost) | Drivers with internal conversion |
| Page-oriented | width |
8 vertical pixels in a column | SSD1306 native format |
The game auto-detects the layout from line_length and adapts the pixel helper accordingly.
Step-by-step C Implementation
Note
The complete source is available in src/embedded-linux/apps/pong-fb/pong_fb.c within the course repository.
Step 1: Open and Query Framebuffer
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <linux/fb.h>
int main(int argc, char *argv[])
{
const char *fb_path = argc > 1 ? argv[1] : "/dev/fb0";
struct fb_var_screeninfo vinfo;
struct fb_fix_screeninfo finfo;
int fb_fd = open(fb_path, O_RDWR);
if (fb_fd < 0) { perror("open"); return 1; }
ioctl(fb_fd, FBIOGET_VSCREENINFO, &vinfo);
ioctl(fb_fd, FBIOGET_FSCREENINFO, &finfo);
int width = vinfo.xres;
int height = vinfo.yres;
int line_length = finfo.line_length;
/* Detect pixel format from line_length */
int page_oriented = (line_length == width);
size_t fb_size = page_oriented
? width * (height / 8)
: line_length * height;
printf("Display: %dx%d, %d bpp (%s)\n", width, height,
vinfo.bits_per_pixel,
page_oriented ? "page-oriented" : "linear");
The same code works on the SSD1306 (128×64 or 128×32) and the BUSE matrix (128×19) — it reads the actual resolution and auto-detects the pixel format from the driver.
Step 2: Map and Define Pixel Helpers
unsigned char *fb_mem = mmap(NULL, fb_size, PROT_READ | PROT_WRITE,
MAP_SHARED, fb_fd, 0);
void set_pixel(int x, int y, int on) {
if (x < 0 || x >= width || y < 0 || y >= height) return;
int byte_idx, bit_idx;
if (page_oriented) {
/* Page format: each byte = 8 vertical pixels in a column */
byte_idx = (y / 8) * width + x;
bit_idx = y % 8;
} else {
/* Linear: each byte = 8 horizontal pixels, LSB = leftmost
* (mainline ssd1307fb convention: bit 0 is leftmost pixel) */
byte_idx = y * line_length + x / 8;
bit_idx = x % 8;
}
if (on) fb_mem[byte_idx] |= (1 << bit_idx);
else fb_mem[byte_idx] &= ~(1 << bit_idx);
}
mmap() with MAP_SHARED maps the framebuffer memory directly into your process — writes are immediately visible to the kernel driver, which pushes them to the display on the next deferred I/O cycle.
Step 3: Draw Primitives
void clear_screen(void) {
memset(fb_mem, 0, fb_size);
}
void draw_rect(int x0, int y0, int w, int h) {
for (int y = y0; y < y0 + h; y++)
for (int x = x0; x < x0 + w; x++)
set_pixel(x, y, 1);
}
Step 4: Game State
/* Adapt paddle to display height */
int paddle_h = height < 32 ? height / 4 : 6;
if (paddle_h < 2) paddle_h = 2;
int paddle_y = (height - paddle_h) / 2;
int ai_y = (height - paddle_h) / 2;
float ball_x = width / 2.0f;
float ball_y = height / 2.0f;
float ball_vx = 1.5f, ball_vy = 1.0f;
int score_left = 0, score_right = 0;
The paddle height scales with the display — 6 pixels for 64-row OLED, 4 pixels for 19-row BUSE.
Step 5: Keyboard Input
Use raw terminal mode to read arrow keys without blocking:
#include <termios.h>
struct termios orig;
tcgetattr(STDIN_FILENO, &orig);
struct termios raw = orig;
raw.c_lflag &= ~(ECHO | ICANON);
raw.c_cc[VMIN] = 0; /* Don't block */
raw.c_cc[VTIME] = 0;
tcsetattr(STDIN_FILENO, TCSANOW, &raw);
/* Read keys: W/S or arrow up/down */
char read_key(void) {
char buf[3];
int n = read(STDIN_FILENO, buf, 3);
if (n == 1 && (buf[0] == 'w' || buf[0] == 'W')) return 'u';
if (n == 1 && (buf[0] == 's' || buf[0] == 'S')) return 'd';
if (n == 1 && (buf[0] == 'q' || buf[0] == 'Q')) return 'q';
if (n == 3 && buf[0] == 27 && buf[1] == '[') {
if (buf[2] == 'A') return 'u'; /* Arrow up */
if (buf[2] == 'B') return 'd'; /* Arrow down */
}
return 0;
}
Warning
Always restore the terminal on exit, otherwise your shell will behave oddly:
Step 6: Game Loop
while (running) {
char key = read_key();
if (key == 'q') break;
if (key == 'u' && paddle_y > 0) paddle_y -= 2;
if (key == 'd' && paddle_y < height - paddle_h) paddle_y += 2;
/* AI: follows ball */
int ai_center = ai_y + paddle_h / 2;
if (ai_center < (int)ball_y - 1) ai_y++;
else if (ai_center > (int)ball_y + 1) ai_y--;
/* Move ball */
ball_x += ball_vx;
ball_y += ball_vy;
/* Bounce off top/bottom walls */
if (ball_y <= 0 || ball_y >= height - 2) ball_vy = -ball_vy;
/* Paddle collisions */
if (ball_x <= 6 && ball_y >= paddle_y && ball_y < paddle_y + paddle_h)
ball_vx = -ball_vx;
if (ball_x >= width - 8 && ball_y >= ai_y && ball_y < ai_y + paddle_h)
ball_vx = -ball_vx;
/* Scoring */
if (ball_x < 0) { score_right++; ball_x = width/2; ball_vx = 1.5f; }
if (ball_x > width) { score_left++; ball_x = width/2; ball_vx = -1.5f; }
/* Draw */
clear_screen();
for (int y = 0; y < height; y += 4) set_pixel(width/2, y, 1); /* net */
draw_rect(4, paddle_y, 2, paddle_h); /* left paddle */
draw_rect(width-6, ai_y, 2, paddle_h); /* right paddle */
draw_rect((int)ball_x, (int)ball_y, 2, 2); /* ball */
msync(fb_mem, fb_size, MS_SYNC);
usleep(1000000 / 30); /* ~30 fps */
}
The msync() call is important for deferred I/O drivers — it tells the kernel the framebuffer memory has changed, triggering a display refresh.
Step 7: Cleanup
printf("\nScore: %d - %d\n", score_left, score_right);
clear_screen();
msync(fb_mem, fb_size, MS_SYNC);
munmap(fb_mem, fb_size);
close(fb_fd);
tcsetattr(STDIN_FILENO, TCSANOW, &orig);
return 0;
}
Build and Run
gcc -o pong_fb pong_fb.c
./pong_fb # uses /dev/fb0 by default
./pong_fb /dev/fb1 # specify a different framebuffer
If you get a permission error, either run with sudo or add a udev rule:
echo 'SUBSYSTEM=="graphics", MODE="0666"' | sudo tee /etc/udev/rules.d/99-fb.rules
sudo udevadm control --reload && sudo udevadm trigger
Controls: - W / Arrow Up — move paddle up - S / Arrow Down — move paddle down - Q — quit
The game auto-detects the resolution and works on both OLED (128×64 or 128×32) and BUSE (128×19) displays.
Challenges
Tip
Try extending the game:
- Score display — draw digit patterns (3×5 pixel font) for each score
- Speed increase — make the ball faster after each rally (increase
ball_vxslightly on each paddle hit) - Two-player mode — use a second set of keys (e.g., I/K) for the right paddle instead of AI
- Sound — play a beep via the buzzer or speaker on paddle hits (use GPIO or
/dev/input)