FPGA Pong
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
- Beginning FPGA Graphics - video signals and basic graphics
- Racing the Beam - simple demo effects with minimal logic
- Pong (this post) - recreate the classic arcade on an FPGA
- Display Signals - revisit display signals and meet colour palettes
- Hardware Sprites - fast, colourful graphics for games
- Framebuffers - bitmap graphics featuring Michelangelo’s David
- Lines and Triangles - drawing lines and triangles
- 2D Shapes - filled shapes and simple pictures
- Animated Shapes - animation and double-buffering
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
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):
- iCEBreaker (iCE40): ice40/top_pong.sv
- Arty (XC7): xc7/top_pong.sv
- Nexys Video (XC7): xc7-dvi/top_pong.sv
- Verilator Sim: sim/top_pong.sv
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.
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. 🙏