Project F

FPGA Pong

Published · Updated

Welcome back to Exploring FPGA Graphics. Last time, we raced the beam; this time, we’ll recreate the arcade classic, Pong and play against our FPGA.

In this series, we learn about graphics at the hardware level and get a feel for the power of FPGAs. We’ll learn how screens work, play Pong, create starfields and sprites, paint Michelangelo’s David, draw lines and triangles, and animate characters and shapes. New to the series? Start with Beginning FPGA Graphics.

Series Outline

Requirements

You should be to run these designs on any recent FPGA board. I include everything you need for the iCEBreaker with 12-Bit DVI Pmod, Digilent Arty A7-35T with Pmod VGA, Digilent Nexys Video with on-board HDMI output, and Verilator Simulation with SDL. See requirements from Beginning FPGA Graphics for more details.

Invading the Living Room

70s gaming room in Computerspielemuseum, Berlin

Photo by Sergey Galyonkin under Creative Commons Attribution-Share Alike licence.

Pong may not have been the first computer game, but it has an excellent claim to being the first to break into public consciousness on release in 1972. Originally an arcade cabinet, it was quickly adapted for play at home with a television.

The Pong Story explains the history of Atari Pong and includes a detailed PDF on the design: Atari Pong E Circuit Analysis & Lawn Tennis. You can also watch Pong gameplay on YouTube.

If you’d like to learn more about the early history of Atari, including Pong, I recommend the book Racing the Beam by Nick Montfort and Ian Bogost.

Pong Design

This design is not based on the detail of the original Pong gameplay. I’ve chosen FPGA design over authenticity, but you’re welcome to change the design however you like (it’s open source):

Try the design out on your board or in simulation; see the README or Sim README for build instructions.

On the iCEBreaker, the controls are:

  • Button 1 - down
  • Button 2 - start (fire)
  • Button 3 - up

On the Arty:

  • BTN0 - down
  • BTN1 - start (fire)
  • BTN3 - up

In the Verilator Sim:

  • Down Arrow - down
  • Space - start (fire)
  • Up Arrow - up

You can edit the simulation keyboard controls in main_pong.cpp.

Playing Pong against the iCEBreaker dev board using DVI Pmod.

Pong in Verilog

Here’s the whole game as a Verilator simulation. Have a glance over it, then scroll down to learn how the different parts of the design work.

module top_pong #(parameter CORDW=10) (    // coordinate width
    input  wire logic clk_pix,             // pixel clock
    input  wire logic sim_rst,             // sim reset
    input  wire logic btn_fire,            // fire button
    input  wire logic btn_up,              // up button
    input  wire logic btn_dn,              // down button
    output      logic [CORDW-1:0] sdl_sx,  // horizontal SDL position
    output      logic [CORDW-1:0] sdl_sy,  // vertical SDL position
    output      logic sdl_de,              // data enable (low in blanking interval)
    output      logic [7:0] sdl_r,         // 8-bit red
    output      logic [7:0] sdl_g,         // 8-bit green
    output      logic [7:0] sdl_b          // 8-bit blue
    );

    // gameplay parameters
    localparam WIN        =  4;  // score needed to win a game (max 9)
    localparam SPEEDUP    =  5;  // speed up ball after this many shots (max 16)
    localparam BALL_SIZE  =  8;  // ball size in pixels
    localparam BALL_ISPX  =  5;  // initial horizontal ball speed
    localparam BALL_ISPY  =  3;  // initial vertical ball speed
    localparam PAD_HEIGHT = 48;  // paddle height in pixels
    localparam PAD_WIDTH  = 10;  // paddle width in pixels
    localparam PAD_OFFS   = 32;  // paddle distance from edge of screen in pixels
    localparam PAD_SPY    =  3;  // vertical paddle speed

    // display sync signals and coordinates
    logic [CORDW-1:0] sx, sy;
    logic de;
    simple_480p display_inst (
        .clk_pix,
        .rst_pix(sim_rst),
        .sx,
        .sy,
        .hsync(),
        .vsync(),
        .de
    );

    // screen dimensions (must match display_inst)
    localparam H_RES = 640;  // horizontal screen resolution
    localparam V_RES = 480;  // vertical screen resolution

    logic frame;  // high for one clock tick at the start of vertical blanking
    always_comb frame = (sy == V_RES && sx == 0);

    // scores
    logic [3:0] score_l;  // left-side score
    logic [3:0] score_r;  // right-side score

    // drawing signals
    logic ball, padl, padr;

    // ball properties
    logic [CORDW-1:0] ball_x, ball_y;  // position (origin at top left)
    logic [CORDW-1:0] ball_spx;        // horizontal speed (pixels/frame)
    logic [CORDW-1:0] ball_spy;        // vertical speed (pixels/frame)
    logic [3:0] shot_cnt;              // shot counter
    logic ball_dx, ball_dy;            // direction: 0 is right/down
    logic ball_dx_prev;                // direction in previous tick (for shot counting)
    logic coll_r, coll_l;              // screen collision flags

    // paddle properties
    logic [CORDW-1:0] padl_y, padr_y;  // vertical position of left and right paddles
    logic [CORDW-1:0] ai_y, play_y;    // vertical position of AI and player paddle

    // link paddles to AI or player
    always_comb begin
        padl_y = play_y;
        padr_y = ai_y;
    end

    // debounce buttons
    logic sig_fire, sig_up, sig_dn;
    debounce deb_fire (.clk(clk_pix), .in(btn_fire), .out(), .ondn(), .onup(sig_fire));
    debounce deb_up (.clk(clk_pix), .in(btn_up), .out(sig_up), .ondn(), .onup());
    debounce deb_dn (.clk(clk_pix), .in(btn_dn), .out(sig_dn), .ondn(), .onup());

    // game state
    enum {NEW_GAME, POSITION, READY, POINT, END_GAME, PLAY} state, state_next;
    always_comb begin
        case (state)
            NEW_GAME: state_next = POSITION;
            POSITION: state_next = READY;
            READY: state_next = (sig_fire) ? PLAY : READY;
            POINT: state_next = (sig_fire) ? POSITION : POINT;
            END_GAME: state_next = (sig_fire) ? NEW_GAME : END_GAME;
            PLAY: begin
                if (coll_l || coll_r) begin
                    if ((score_l == WIN) || (score_r == WIN)) state_next = END_GAME;
                    else state_next = POINT;
                end else state_next = PLAY;
            end
            default: state_next = NEW_GAME;
        endcase
        if (sim_rst) state_next = NEW_GAME;
    end

    // update game state
    always_ff @(posedge clk_pix) state <= state_next;

    // AI paddle control
    always_ff @(posedge clk_pix) begin
        if (state == POSITION) ai_y <= (V_RES - PAD_HEIGHT)/2;
        else if (frame && state == PLAY) begin
            if (ai_y + PAD_HEIGHT/2 < ball_y) begin  // ball below
                if (ai_y + PAD_HEIGHT + PAD_SPY >= V_RES-1) begin  // bottom of screen?
                    ai_y <= V_RES - PAD_HEIGHT - 1;  // move down as far as we can
                end else ai_y <= ai_y + PAD_SPY;  // move down
            end else if (ai_y + PAD_HEIGHT/2 > ball_y + BALL_SIZE) begin // ball above
                if (ai_y < PAD_SPY) begin  // top of screen
                    ai_y <= 0;  // move up as far as we can
                end else ai_y <= ai_y - PAD_SPY;  // move up
            end
        end
    end

    // Player paddle control
    always_ff @(posedge clk_pix) begin
        if (state == POSITION) play_y <= (V_RES - PAD_HEIGHT)/2;
        else if (frame && state == PLAY) begin
            if (sig_dn) begin
                if (play_y + PAD_HEIGHT + PAD_SPY >= V_RES-1) begin  // bottom of screen?
                    play_y <= V_RES - PAD_HEIGHT - 1;  // move down as far as we can
                end else play_y <= play_y + PAD_SPY;  // move down
            end else if (sig_up) begin
                if (play_y < PAD_SPY) begin  // top of screen
                    play_y <= 0;  // move up as far as we can
                end else play_y <= play_y - PAD_SPY;  // move up
            end
        end
    end

    // ball control
    always_ff @(posedge clk_pix) begin
        case (state)
            NEW_GAME: begin
                score_l <= 0;  // reset score
                score_r <= 0;
            end

            POSITION: begin
                coll_l <= 0;  // reset screen collision flags
                coll_r <= 0;
                ball_spx <= BALL_ISPX;  // reset speed
                ball_spy <= BALL_ISPY;
                shot_cnt <= 0;  // reset shot count

                // centre ball vertically and position on paddle (right or left)
                ball_y <= (V_RES - BALL_SIZE)/2;
                if (coll_r) begin
                    ball_x <= H_RES - (PAD_OFFS + PAD_WIDTH + BALL_SIZE);
                    ball_dx <= 1;  // move left
                end else begin
                    ball_x <= PAD_OFFS + PAD_WIDTH;
                    ball_dx <= 0;  // move right
                end
            end

            PLAY: begin
                if (frame) begin
                    // horizontal ball position
                    if (ball_dx == 0) begin  // moving right
                        if (ball_x + BALL_SIZE + ball_spx >= H_RES-1) begin
                            ball_x <= H_RES-BALL_SIZE;  // move to edge of screen
                            score_l <= score_l + 1;
                            coll_r <= 1;
                        end else ball_x <= ball_x + ball_spx;
                    end else begin  // moving left
                        if (ball_x < ball_spx) begin
                            ball_x <= 0;  // move to edge of screen
                            score_r <= score_r + 1;
                            coll_l <= 1;
                        end else ball_x <= ball_x - ball_spx;
                    end

                    // vertical ball position
                    if (ball_dy == 0) begin  // moving down
                        if (ball_y + BALL_SIZE + ball_spy >= V_RES-1)
                            ball_dy <= 1;  // move up next frame
                        else ball_y <= ball_y + ball_spy;
                    end else begin  // moving up
                        if (ball_y < ball_spy)
                            ball_dy <= 0;  // move down next frame
                        else ball_y <= ball_y - ball_spy;
                    end

                    // ball speed increases after SPEEDUP shots
                    if (ball_dx_prev != ball_dx) shot_cnt <= shot_cnt + 1;
                    if (shot_cnt == SPEEDUP) begin  // increase ball speed
                        ball_spx <= (ball_spx < PAD_WIDTH) ? ball_spx + 1 : ball_spx;
                        ball_spy <= ball_spy + 1;
                        shot_cnt <= 0;
                    end
                end
            end
        endcase

        // change direction if ball collides with paddle
        if (ball && padl && ball_dx==1) ball_dx <= 0;  // left paddle
        if (ball && padr && ball_dx==0) ball_dx <= 1;  // right paddle

        // record ball direction in previous frame
        if (frame) ball_dx_prev <= ball_dx;
    end

    // check for ball and paddles at current screen position (sx,sy)
    always_comb begin
        ball = (sx >= ball_x) && (sx < ball_x + BALL_SIZE)
               && (sy >= ball_y) && (sy < ball_y + BALL_SIZE);
        padl = (sx >= PAD_OFFS) && (sx < PAD_OFFS + PAD_WIDTH)
               && (sy >= padl_y) && (sy < padl_y + PAD_HEIGHT);
        padr = (sx >= H_RES - PAD_OFFS - PAD_WIDTH - 1) && (sx < H_RES - PAD_OFFS - 1)
               && (sy >= padr_y) && (sy < padr_y + PAD_HEIGHT);
    end

    // draw the score
    logic pix_score;  // pixel of score char
    simple_score simple_score_inst (
        .clk_pix,
        .sx,
        .sy,
        .score_l,
        .score_r,
        .pix(pix_score)
    );

    // paint colour
    logic [3:0] paint_r, paint_g, paint_b;
    always_comb begin
        if (pix_score) {paint_r, paint_g, paint_b} = 12'hF30;  // score
        else if (ball) {paint_r, paint_g, paint_b} = 12'hFC0;  // ball
        else if (padl || padr) {paint_r, paint_g, paint_b} = 12'hFFF;  // paddles
        else {paint_r, paint_g, paint_b} = 12'h137;  // background
    end

    // display colour: paint colour but black in blanking interval
    logic [3:0] display_r, display_g, display_b;
    always_comb begin
        display_r = (de) ? paint_r : 4'h0;
        display_g = (de) ? paint_g : 4'h0;
        display_b = (de) ? paint_b : 4'h0;
    end

    // SDL output (8 bits per colour channel)
    always_ff @(posedge clk_pix) begin
        sdl_sx <= sx;
        sdl_sy <= sy;
        sdl_de <= de;
        sdl_r <= {2{display_r}};
        sdl_g <= {2{display_g}};
        sdl_b <= {2{display_b}};
    end
endmodule

Parameters and Properties

The first lines of the module let you configure gameplay parameters. The parameters should be self-explanatory, but you cannot set the ‘WIN’ score to more than 9 because the score display only supports one digit.

localparam WIN        =  4;  // score needed to win a game (max 9)
localparam SPEEDUP    =  5;  // speed up ball after this many shots (max 16)
localparam BALL_SIZE  =  8;  // ball size in pixels
localparam BALL_ISPX  =  5;  // initial horizontal ball speed
localparam BALL_ISPY  =  3;  // initial vertical ball speed
localparam PAD_HEIGHT = 48;  // paddle height in pixels
localparam PAD_WIDTH  = 10;  // paddle width in pixels
localparam PAD_OFFS   = 32;  // paddle distance from edge of screen in pixels
localparam PAD_SPY    =  3;  // vertical paddle speed

Display Signals

This section deals with display sync signals, coordinates, and the frame signal. This design is unchanged from ‘Colour Cycle’ in Racing the Beam.

Variable Declarations

We then have a big block of variable declarations.

This is followed by a little logic to connect the left and right paddles to the player or AI:

always_comb begin
    padl_y = play_y;
    padr_y = ai_y;
end

Switch sides here if you prefer to play as the right paddle. You can also set two AI paddles against each other (though you still need to start each point by pressing fire).

Debouncing

To safely use buttons in our design, we need to debounce them. Debouncing ensures we get a single, clean transition from button presses. The debounce module looks like this [debounce.sv]:

module debounce (
    input  wire logic clk,   // clock
    input  wire logic in,    // signal input
    output      logic out,   // signal output (debounced)
    output      logic ondn,  // on down (one tick)
    output      logic onup   // on up (one tick)
    );

    // sync with clock and combat metastability
    logic sync_0, sync_1;
    always_ff @(posedge clk) sync_0 <= in;
    always_ff @(posedge clk) sync_1 <= sync_0;

    logic [17:0] cnt;  // 2^18 = 2.6 ms counter at 100 MHz
    logic idle, max;
    always_comb begin
        idle = (out == sync_1);
        max  = &cnt;
        ondn = ~idle & max & ~out;
        onup = ~idle & max & out;
    end

    always_ff @(posedge clk) begin
        if (idle) begin
            cnt <= 0;
        end else begin
            cnt <= cnt + 1;
            if (max) out <= ~out;
        end
    end
endmodule

We want continuous signals for up and down, but a one-off event for the fire button:

logic sig_fire, sig_up, sig_dn;
debounce deb_fire (.clk(clk_pix), .in(btn_fire), .out(), .ondn(), .onup(sig_fire));
debounce deb_up (.clk(clk_pix), .in(btn_up), .out(sig_up), .ondn(), .onup());
debounce deb_dn (.clk(clk_pix), .in(btn_dn), .out(sig_dn), .ondn(), .onup());

The onup() and ondown() outputs work like traditional UI events. In this case, sig_fire will be high for one tick when the fire button is released.

The State of Play

We don’t have a CPU, so we use a finite state machine (FSM) to keep track of the game state. The player controls the state of the game with the fire button (spacebar in simulation):

enum {NEW_GAME, POSITION, READY, POINT, END_GAME, PLAY} state, state_next;
always_comb begin
    case (state)
        NEW_GAME: state_next = POSITION;
        POSITION: state_next = READY;
        READY: state_next = (sig_fire) ? PLAY : READY;
        POINT: state_next = (sig_fire) ? POSITION : POINT;
        END_GAME: state_next = (sig_fire) ? NEW_GAME : END_GAME;
        PLAY: begin
            if (coll_l || coll_r) begin
                if ((score_l == WIN) || (score_r == WIN)) state_next = END_GAME;
                else state_next = POINT;
            end else state_next = PLAY;
        end
        default: state_next = NEW_GAME;
    endcase
    if (sim_rst) state_next = NEW_GAME;
end

The state machine is straightforward; the only complex state change is PLAY. When there’s a collision with the left or right of the screen, a point is over; if either player has reached the WIN score, the game is over.

NB. The reset signal ensures the state machine begins in a known state.

Paddle Position

A paddle may be controlled by the player or the AI; each has separate logic. In the POSITION state, the paddle is returned to the vertical centre of the screen.

// AI paddle control
always_ff @(posedge clk_pix) begin
    if (state == POSITION) ai_y <= (V_RES - PAD_HEIGHT)/2;
    else if (frame && state == PLAY) begin
        if (ai_y + PAD_HEIGHT/2 < ball_y) begin  // ball below
            if (ai_y + PAD_HEIGHT + pad_spy >= V_RES-1) begin  // bottom of screen?
                ai_y <= V_RES - PAD_HEIGHT - 1;  // move down as far as we can
            end else ai_y <= ai_y + pad_spy;  // move down
        end else if (ai_y + PAD_HEIGHT/2 > ball_y + BALL_SIZE) begin // ball above
            if (ai_y < pad_spy) begin  // top of screen
                ai_y <= 0;  // move up as far as we can
            end else ai_y <= ai_y - pad_spy;  // move up
        end
    end
end

In play, the AI compares the centre of the paddle to the top of the ball. If the ball is below, it moves the paddle down as far as possible (without going beyond the bottom of the screen). Otherwise, it compares the middle of the paddle to the bottom of the ball, and if the ball is above, it moves up.

If the middle of the paddle is level with any part of the ball, we don’t move the paddle; this avoids jerky paddle movement.

// Player paddle control
always_ff @(posedge clk_pix) begin
    if (state == POSITION) play_y <= (V_RES - PAD_HEIGHT)/2;
    else if (frame && state == PLAY) begin
        if (sig_dn) begin
            if (play_y + PAD_HEIGHT + pad_spy >= V_RES-1) begin  // bottom of screen?
                play_y <= V_RES - PAD_HEIGHT - 1;  // move down as far as we can
            end else play_y <= play_y + pad_spy;  // move down
        end else if (sig_up) begin
            if (play_y < pad_spy) begin  // top of screen
                play_y <= 0;  // move up as far as we can
            end else play_y <= play_y - pad_spy;  // move up
        end
    end
end

The player control is similar but responds to user input instead of the ball position.

Ball Control

The ball logic is the most complex part of the design; we consider each state in turn.

At the start of a game, we reset player scores:

NEW_GAME: begin
    score_l <= 0;  // reset score
    score_r <= 0;
end

Position prepares the ball for the next point:

POSITION: begin
    coll_l <= 0;  // reset screen collision flags
    coll_r <= 0;
    ball_spx <= BALL_ISPX;  // reset speed
    ball_spy <= BALL_ISPY;
    shot_cnt <= 0;  // reset shot count

    // centre ball vertically and position on paddle (right or left)
    ball_y <= (V_RES - BALL_SIZE)/2;
    if (coll_r) begin
        ball_x <= H_RES - (PAD_OFFS + PAD_WIDTH + BALL_SIZE);
        ball_dx <= 1;  // move left
    end else begin
        ball_x <= PAD_OFFS + PAD_WIDTH;
        ball_dx <= 0;  // move right
    end
end

In play, we check for ball collisions with the edge of the screen. If the ball collides with the left or right edge of the screen, then a point is over: we record this by setting the coll_r or coll_l flag. These collision flags are used by the finite state machine (above).

If the ball collides with the top or bottom of the screen, we reverse direction, so it bounces.

PLAY: begin
    if (frame) begin
        // horizontal ball position
        if (ball_dx == 0) begin  // moving right
            if (ball_x + BALL_SIZE + ball_spx >= H_RES-1) begin
                ball_x <= H_RES-BALL_SIZE;  // move to edge of screen
                score_l <= score_l + 1;
                coll_r <= 1;
            end else ball_x <= ball_x + ball_spx;
        end else begin  // moving left
            if (ball_x < ball_spx) begin
                ball_x <= 0;  // move to edge of screen
                score_r <= score_r + 1;
                coll_l <= 1;
            end else ball_x <= ball_x - ball_spx;
        end

        // vertical ball position
        if (ball_dy == 0) begin  // moving down
            if (ball_y + BALL_SIZE + ball_spy >= V_RES-1)
                ball_dy <= 1;  // move up next frame
            else ball_y <= ball_y + ball_spy;
        end else begin  // moving up
            if (ball_y < ball_spy)
                ball_dy <= 0;  // move down next frame
            else ball_y <= ball_y - ball_spy;
        end

        // ball speed increases after SPEEDUP shots
        if (ball_dx_prev != ball_dx) shot_cnt <= shot_cnt + 1;
        if (shot_cnt == SPEEDUP) begin  // increase ball speed
            ball_spx <= (ball_spx < PAD_WIDTH) ? ball_spx + 1 : ball_spx;
            ball_spy <= ball_spy + 1;
            shot_cnt <= 0;
        end
    end
end

The final part of the play state handles the ball speed. After SPEEDUP shots have occurred, we increase the vertical and horizontal ball speed by one. However, we don’t want the horizontal speed to be larger than the paddle width, or the collision logic won’t work, and it’s this collision logic we turn to next.

// change direction if ball collides with paddle
if (ball && padl && ball_dx==1) ball_dx <= 0;  // left paddle
if (ball && padr && ball_dx==0) ball_dx <= 1;  // right paddle

// record ball direction in previous frame
if (frame) ball_dx_prev <= ball_dx;

We use the drawing signals to detect collisions between the ball and paddles, so the collision check occurs on every rising clock edge, not once per frame.

The three drawing signals are continuouly evaluated; a collision occurs if both the ball and a paddle are high at the same time:

// check for ball and paddles at current screen position (sx,sy)
always_comb begin
    ball = (sx >= ball_x) && (sx < ball_x + BALL_SIZE)
            && (sy >= ball_y) && (sy < ball_y + BALL_SIZE);
    padl = (sx >= PAD_OFFS) && (sx < PAD_OFFS + PAD_WIDTH)
            && (sy >= padl_y) && (sy < padl_y + PAD_HEIGHT);
    padr = (sx >= H_RES - PAD_OFFS - PAD_WIDTH - 1) && (sx < H_RES - PAD_OFFS - 1)
            && (sy >= padr_y) && (sy < padr_y + PAD_HEIGHT);
end

Using the drawing signals to detect ball-paddle collisions avoids the need to model collisions between moving objects.

This method is reliable, provided the horizontal speed of the ball isn’t greater than the paddle width; in that case, the ball can pass right through the paddle! We avoid this problem by limiting the maximum horizontal ball speed (discussed above).

A similar problem occurs with the vertical ball speed and paddle height, but this requires ludicrous speeds for reasonable paddle height.

Keeping Score

We use a dedicated module to draw the score for both players:

module simple_score #(
    parameter CORDW=10,                // coordinate width
    parameter H_RES=640                // horizontal screen resolution
    ) (
    input  wire logic clk_pix,         // pixel clock
    input  wire logic [CORDW-1:0] sx,  // horizontal screen position
    input  wire logic [CORDW-1:0] sy,  // vertical screen position
    input  wire logic [3:0] score_l,   // score for left-side player (0-9)
    input  wire logic [3:0] score_r,   // score for right-side player (0-9)
    output      logic pix              // draw pixel at this position?
    );

    // number characters: MSB first, so we can write pixels left to right
    logic [0:14] chars [10];  // ten characters of 15 pixels each
    initial begin
        chars[0] = 15'b111_101_101_101_111;
        chars[1] = 15'b110_010_010_010_111;
        chars[2] = 15'b111_001_111_100_111;
        chars[3] = 15'b111_001_011_001_111;
        chars[4] = 15'b101_101_111_001_001;
        chars[5] = 15'b111_100_111_001_111;
        chars[6] = 15'b100_100_111_101_111;
        chars[7] = 15'b111_001_001_001_001;
        chars[8] = 15'b111_101_111_101_111;
        chars[9] = 15'b111_101_111_001_001;
    end

    // ensure score in range of characters (0-9)
    logic [3:0] char_l, char_r;
    always_comb begin
        char_l = (score_l < 10) ? score_l : 0;
        char_r = (score_r < 10) ? score_r : 0;
    end

    // set screen region for each score: 12x20 pixels (8,8) from corner
    // subtract one from 'sx' to account for latency for registering 'pix'
    logic score_l_region, score_r_region;
    always_comb begin
        score_l_region = (sx >= 7 && sx < 19 && sy >= 8 && sy < 28);
        score_r_region = (sx >= H_RES-22 && sx < H_RES-10 && sy >= 8 && sy < 28);
    end

    // determine character pixel address from screen position (scale 4x)
    always_comb begin
        if (score_l_region) pix_addr = (sx-7)/4 + 3*((sy-8)/4);
        else if (score_r_region) pix_addr = (sx-(H_RES-22))/4 + 3*((sy-8)/4);
        else pix_addr = 0;
    end

    // score pixel for current screen position
    logic [3:0] pix_addr;
    always_ff @(posedge clk_pix) begin
        if (score_l_region) pix <= chars[char_l][pix_addr];
        else if (score_r_region) pix <= chars[char_r][pix_addr];
        else pix <= 0;
    end
endmodule

This module is neither flexible nor elegant, but it gets the job done until we learn about sprites. Each number, 0-9, is represented by five rows of three pixels. We scale the score bitmaps by four, so they’re not tiny.

At least the module is easy to use:

logic pix_score;  // pixel of score char
simple_score simple_score_inst (
    .clk_pix,
    .sx,
    .sy,
    .score_l,
    .score_r,
    .pix(pix_score)
);

Colours

Original Pong was a monochrome affair, but I’ve chosen to support separate colours for the score, ball, paddles, and background. Whether you want traditional monochrome or pink and green, you can select your own colour scheme with this logic; see 12-bit Colour if you need a reminder on how our colour system works.

    // paint colour
    logic [3:0] paint_r, paint_g, paint_b;
    always_comb begin
        if (pix_score) {paint_r, paint_g, paint_b} = 12'hF30;  // score
        else if (ball) {paint_r, paint_g, paint_b} = 12'hFC0;  // ball
        else if (padl || padr) {paint_r, paint_g, paint_b} = 12'hFFF;  // paddles
        else {paint_r, paint_g, paint_b} = 12'h137;  // background
    end

We won’t cover the video output here, as we’ve already discussed it in Beginning FPGA Graphics.

Explore

I hope you enjoyed this instalment of Exploring FPGA Graphics, but nothing beats creating your own designs. Here are a few suggestions to improve FPGA Pong:

  • Experiment with the gameplay parameters
  • Change the background colour at the end of the game
  • Draw a net down the middle of the screen
  • Update the vertical ball direction depending on where it hits the paddle

If you’re looking for a bigger challenge, try creating the game Snake.

What’s Next?

Next time in FPGA Graphics, we’ll revisit display signals and learn about palettes and indexed colour. You can also check out my other FPGA & RISC-V Tutorials.

Get in touch on Mastodon, Bluesky, or X. If you enjoy my work, please sponsor me. 🙏