Published: 2026-07-28 | By: Carlos Montiel | Reading time: ~4 minutes
Pong is the 'hello world' of video games. If you understand how to build it from scratch, you understand the structure shared by practically every real-time game.
Project Structure
In the previous lesson we got the environment ready with SDL2 and CMake. Now we'll use that exact foundation to build Pong: two paddles, a bouncing ball, and a simple scoreboard.
pong/
├── CMakeLists.txt
├── src/
│ └── main.cpp
└── build/
The whole game will live in a single `main.cpp` for simplicity — in later lessons we'll split the code into multiple files once the project justifies it. For now, the priority is that you understand the full flow without jumping between files.
The Main Game Loop
Every video game, regardless of complexity, has the same skeleton: process input, update the world's state, and draw. This is called the "game loop," and it's probably the single most important concept in this whole course.
#include <SDL2/SDL.h>
#include <iostream>
const int WIDTH = 800;
const int HEIGHT = 600;
int main(int argc, char* argv[]) {
SDL_Init(SDL_INIT_VIDEO);
SDL_Window* window = SDL_CreateWindow("Pong", SDL_WINDOWPOS_CENTERED,
SDL_WINDOWPOS_CENTERED, WIDTH, HEIGHT, SDL_WINDOW_SHOWN);
SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);
bool running = true;
Uint32 last_time = SDL_GetTicks();
while (running) {
// 1. Process input
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_QUIT) running = false;
}
// 2. Compute delta time (time between frames)
Uint32 current_time = SDL_GetTicks();
float delta = (current_time - last_time) / 1000.0f;
last_time = current_time;
// 3. Update (game logic goes here)
// 4. Draw
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_RenderClear(renderer);
SDL_RenderPresent(renderer);
SDL_Delay(16); // roughly 60 frames per second
}
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}
The `delta` (time elapsed between one frame and the next) is key: instead of moving objects a fixed number of pixels per frame, we move them an amount proportional to the actual time elapsed. This makes the game behave the same on a fast computer as on a slow one.
Defining the Paddles and the Ball
We'll represent each game element with an `SDL_Rect` (for the paddles, which are rectangles) and position/velocity variables for the ball.
struct Paddle {
SDL_Rect rect;
float velocity_y = 0.0f;
const float SPEED = 400.0f; // pixels per second
};
struct Ball {
SDL_Rect rect;
float vel_x = 300.0f;
float vel_y = 200.0f;
};
Paddle player1 { { 30, HEIGHT / 2 - 50, 15, 100 } };
Paddle player2 { { WIDTH - 45, HEIGHT / 2 - 50, 15, 100 } };
Ball ball { { WIDTH / 2 - 8, HEIGHT / 2 - 8, 16, 16 } };
Reading Player Input
For smoother control than reacting to discrete events, we use `SDL_GetKeyboardState`, which gives us the state of every key on each frame.
const Uint8* keyboard = SDL_GetKeyboardState(nullptr);
// Player 1: W / S
player1.velocity_y = 0.0f;
if (keyboard[SDL_SCANCODE_W]) player1.velocity_y = -player1.SPEED;
if (keyboard[SDL_SCANCODE_S]) player1.velocity_y = player1.SPEED;
// Player 2: up / down arrows
player2.velocity_y = 0.0f;
if (keyboard[SDL_SCANCODE_UP]) player2.velocity_y = -player2.SPEED;
if (keyboard[SDL_SCANCODE_DOWN]) player2.velocity_y = player2.SPEED;
player1.rect.y += static_cast<int>(player1.velocity_y * delta);
player2.rect.y += static_cast<int>(player2.velocity_y * delta);
// Keep the paddles from leaving the screen
if (player1.rect.y < 0) player1.rect.y = 0;
if (player1.rect.y + player1.rect.h > HEIGHT) player1.rect.y = HEIGHT - player1.rect.h;
if (player2.rect.y < 0) player2.rect.y = 0;
if (player2.rect.y + player2.rect.h > HEIGHT) player2.rect.y = HEIGHT - player2.rect.h;
Moving the Ball and Bouncing Off the Edges
ball.rect.x += static_cast<int>(ball.vel_x * delta);
ball.rect.y += static_cast<int>(ball.vel_y * delta);
// Bounce off top/bottom
if (ball.rect.y <= 0 || ball.rect.y + ball.rect.h >= HEIGHT) {
ball.vel_y = -ball.vel_y;
}
// Point for a player (ball leaves through the left or right edge)
if (ball.rect.x < 0) {
std::cout << "Point for player 2" << std::endl;
ball.rect.x = WIDTH / 2 - 8;
ball.rect.y = HEIGHT / 2 - 8;
ball.vel_x = 300.0f;
}
if (ball.rect.x > WIDTH) {
std::cout << "Point for player 1" << std::endl;
ball.rect.x = WIDTH / 2 - 8;
ball.rect.y = HEIGHT / 2 - 8;
ball.vel_x = -300.0f;
}
Notice we haven't yet handled collisions between the ball and the paddles — we'll formalize that with collision math in lesson 5. For now, to have a playable Pong today, we can do a simple check with `SDL_HasIntersection`:
if (SDL_HasIntersection(&ball.rect, &player1.rect) ||
SDL_HasIntersection(&ball.rect, &player2.rect)) {
ball.vel_x = -ball.vel_x;
}
Drawing Everything on Screen
SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, 255, 255, 255, 255);
SDL_RenderFillRect(renderer, &player1.rect);
SDL_RenderFillRect(renderer, &player2.rect);
SDL_RenderFillRect(renderer, &ball.rect);
SDL_RenderPresent(renderer);
Compiling and Testing
mkdir build && cd build
cmake ..
cmake --build .
./game
You should see two white paddles and a ball moving, with W/S and the arrow keys controlling each side. If something doesn't compile, this is a great moment to practice your workflow with your AI assistant: paste the full compiler error (not just the last line) and ask it to explain the cause before applying the fix — that way you learn what the error meant, not just how it disappeared.
In the next lesson we'll replace these white rectangles with real sprites, loading images with SDL2_image and animating them frame by frame.