How to make Pong using C++ and SDL3 in a day
Step 0: learn C++ and SDL
SDL provides easy access to keyboard, mouse, audio, and graphics hardware, more than enough for a simple pong game.
Good resources
The plan
A simple game has the following structure
Initialize_Game_Components();
while (playing) {
Handle_User_Inputs();
Update_Game_State();
Render_Screen();
}
Clean_Up();
Specifically for pong, each of the preceding function may do the following
Initialize_Game_Components() {
Create objects like the ball and paddles
Set initial positions of objects
Initialize scores
...
}
Handle_User_Inputs() {
if user presses "up":
Move paddle up
if user quits:
Clean up
}
Update_Game_State() {
for each game object:
Move it at its velocity
for each pair of objects:
Check for collision
for each collision:
Handle collision
}
Render_Screen() {
for each object:
draw it
}
Clean_Up() {
delete objects and free memory
}
It’s clear that there are three categories components to this game
- game objects
- individual items like the ball and the paddles
- game state
- a system that manages game information, objects and their interactions
- main loop
- the place that uses the game state and game objects, and provides user inputs and other events
- SDL3 handles this component
The section below explores game objects and the game state further.
Game objects
A simple pong game only has a few components:
- a ball
- two paddles
- two scores
The ball and the two paddles need collision detection and handling, and movement, while the scores only need to update the text they display.
To make boundary detection more extendable, add four ‘walls’, one of each side, and treat them as collidable objects.
Instead of changing the properties of collided objects immediately upon collision detection, it’s best to first detect all collisions, and then handle them in the right order, so that each object has the correct information when deciding how to react to a collision.
In summary, each game obejct should keep track of its own
- position
- current velocity
- identity
- initial position and velocity (for resetting)
and
- the next position and velocity after a collision
Further, each object needs to be able to
- update its own position based on its velocity
- render itself
- register a collision with another object
- handle a registered collision
In addition, there are two text objects that need to
- render text using a font
- update text
Game state
The game state, or app state, needs to keep track of all the objects and their interactions, as well as game information like the scores. It also provides the renderer to objects and manipulate them based on user input.
What it needs to do
- initialize and clean up game objects
- keep scores
- start and reset games
- move paddles based on user input
- update objects
- check collisions between objects and make sure the objects handle them
- render screen
Having a game state object makes implementing the game loop simple. The end product may look something like this
...
as = Appstate();
...
while (playing) {
as->UpdatePositions();
as->CheckCollisions();
as->ProcessCollisions();
as->Render();
}
with additional event handling for user input.
Implementation
This repository contains the full implementation.
Classes
There should be a class for game objects and one for the game state. Text objects can be a child of the game object class with additional text capability.
For example, each game object can have the following member functions and variables. The full implementation contains more helper functions.
void UpdatePos(); // called every iteration of the main loop
SDL_Rect *GetCollisionBox(); // for collision detection
void Render(SDL_Renderer *renderer);// to render itself
void RegisterCollision(Body *body); // to plan what to do after colliding
void HandleCollision(); // to enact changes after colliding
void Reset(); // to return to the initial state
GraphicBox mGraphicBox; // color, position, and collision box
RigidBody mRigidBody; // velocity
Id mId; // identity
SDL_Rect mPostCollisionPos{}; // for collision handling
int mPostCollisionXvel{};
int mPostCollisionYvel{};
bool mCollided{false};
GraphicBox mInitGraphicBox; // for resetting
RigidBody mInitRigidBody;
A text object can have the following additional members
void setText(std::string text, SDL_Renderer *renderer);
std::string mText;
TTF_Font *mFont;
SDL_Texture *mFontTexture;
A game state object can have the following members
void startGame(bool ai);
void incScore(Side side);
void decScore(Side side);
void moveBar(Side side, BarDirection dir); // called after input events
SDL_Window *getWindow();
SDL_Renderer *getRenderer();
void UpdatePositions(); // called in the mainloop
void CheckCollisions(); // called in the mainloop
void ProcessCollisions(); // called in the mainloop
void Render(); // called in the mainloop
SDL_Window *mWindow;
SDL_Renderer *mRenderer;
int mLeftScore;
int mRightScore;
TextBody *mLeftScoreBody; // game objects
TextBody *mRightScoreBody;
Body *mBall;
Body *mLeftBar;
Body *mRightBar;
// invisible bodies
Body *mTopWall;
Body *mBottomWall;
Body *mLeftWall;
Body *mRightWall;
Notes on implementation
Because of the detailed plan, implementation is quite straightforward. However, there are some areas that could use more explanation.
Position, color, velocity
The following definitions encapsulates this information
struct GraphicBox {
SDL_Rect rect; // size and position
SDL_Color color;
};
struct RigidBody { // velocity
int xvel;
int yvel;
};
Since the position and size of the object is perfectly described by the position and size of its appearance on screen, GraphicBox
doubles as graphics and collision box. These structs aren’t necessary since they carry little information, and their members can simply be member variables of the object.
Registering collision
Since there is no child classes for different types of game objects, registering what to do after a collision involves a lot more code on testing the identity of the two colliding objects and choosing the correct behavior. In pseudo-code, it looks like:
if (this is a ball) {
if (the other is a paddle) {
reverse velocity in the x-axis;
}
if (the other is a top or bottom wall) {
reverse velocity in the y-axis;
}
...
} else if (this is a paddle) {
if (the other is a top or bottom wall) {
stop going into the wall;
}
...
}
Game object initialization
The constructor of the game state object decides the dimensions, looks, initial positions, and velocity of game objects. A lot of these values involve scaling screen dimensions with magic numbers obtained through testing.
There are better ways to initialize them, such as defining clear constants, or having different children classes for each object type that initialize themselves.
Score objects require a call to the function that increases or decreases the score once to render for the first time, which is why the startGame
function sets the scores to -1
and increments them immediately. Also, for score keeping, likely one function would be enough although currently there are two, one for incrementing and one for decrementing.
Detecting collisions
The game state object only has to check collisions for objects that could collide. For example, the top wall and the bottom wall never move or tap each other. In the same vein, only some objects need to register collisions. For example, the ball needs to update itself after colliding with anything, but the paddles don’t need to update after colliding with the ball.
Game loop
To remove platform-specific details, and make compiling to WebAssembly easier, it’s best to use main callbacks in SDL3.
SDL_AppInit
contains mostly boilerplate code since the game state’s own constructor does the heavy-lifting.
SDL_AppIterate
contains calls to various functions in the game state object
SdlPong::AppState *as = static_cast<SdlPong::AppState *>(appstate);
as->UpdatePositions();
as->CheckCollisions();
as->ProcessCollisions();
as->Render();
SDL_AppEvent
handles events like keyboard inputs, meaning it calls the moveBar
and startGame
functions of the game state object.
Result
Appendix: compiling SDL3 projects using emscripten
As of writing this post, emscripten doesn’t have a port for SDL3, which is still under rapid development. So you have to build SDL from source using emscripten following this guide. In short, inside the repository,
mkdir build
cd build
emcmake cmake ..
emmake make
To use SDL3_ttf, you have to do the same for SDL_ttf, but with some additional steps.
First, you may have to build freetype with emscripten to use it when building SDL3_ttf.
Second, before building SDL3_ttf, you may have to check the declaration of SDL_CreateHashTable
within SDL3_ttf. As of this commit, there is a mismatch between this and the version in the main SDL repository. Without changing anything, comiling your project may result in this warning:
wasm-ld: warning: function signature mismatch: SDL_CreateHashTable
>>> defined as (i32, i32, i32, i32, i32, i32) -> i32 in (build dir)/SDL_ttf/build/libSDL3_ttf.a(SDL_ttf.c.o)
>>> defined as (i32, i32, i32, i32, i32, i32, i32) -> i32 in (build dir)/SDL/build/libSDL3.a(SDL_hashtable.c.o)
and the script would have a runtime error.
To fix it, change the declaration of SDL_CreateHashTable
to match that in SDL3, by adding an argument bool threadsafe
, and change all invocations of that function within SDL_ttf to use an additional argument, which can simply be false
.
Then, use emscripten to compile SDL3_ttf
mkdir build
cd build
emcmake cmake .. -DSDL3_DIR=$EMSCRIPTEN_BUILD_DIR/SDL/build -DBUILD_SHARED_LIBS=OFF -DFREETYPE_LIBRARY=$EMSCRIPTEN_BUILD_DIR/freetype/build/libfreetype.a -DFREETYPE_INCLUDE_DIRS=$EMSCRIPTEN_BUILD_DIR/freetype/include
emmake make
$EMSCRIPTEN_BUILD_DIR
is where you built SDL3 and freetype with emscripten.
Finally, to build the SDL pong project1, make sure to include and link the newly built packages and libraries,
mkdir build
cd build
em++ -o index.html -I$EMSCRIPTEN_BUILD_DIR/freetype/include/ -I$EMSCRIPTEN_BUILD_DIR/SDL/include -I$EMSCRIPTEN_BUILD_DIR/SDL_ttf/include $EMSCRIPTEN_BUILD_DIR/freetype/build/libfreetype.a $EMSCRIPTEN_BUILD_DIR/SDL_ttf/build/libSDL3_ttf.a $EMSCRIPTEN_BUILD_DIR/SDL/build/libSDL3.a ../game.cpp ../sdl_pong.cpp --embed-file ../slkscr.ttf@/font/slkscr.ttf -std=c++20
-
the build command assume the default font path in
sdl_pong.cpp
has changed to/font/slkscr.ttf
↩