Skip to content

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:

cd ~
git clone https://github.com/OE-KVK-H2IoT/embedded-linux.git

Source files for this tutorial are in ~/embedded-linux/apps/pong-fb/.


How Framebuffer Apps Work

A user-space framebuffer application follows this pattern:

  1. Openopen("/dev/fb0", O_RDWR)
  2. Queryioctl(FBIOGET_VSCREENINFO) returns width, height, bits-per-pixel
  3. Mapmmap() maps the framebuffer memory into your process
  4. Draw — write pixel data directly into the mapped memory
  5. Syncmsync() triggers the deferred I/O refresh (for drivers that use it)
  6. Cleanupmunmap(), 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:

tcsetattr(STDIN_FILENO, TCSANOW, &orig);


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_vx slightly 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)

Course Overview