Build a Twin-Stick Shooter with SDL3
Build a native macOS game from scratch using C++ and SDL3.
Introduction
30 Second Summary
Every game you play handles thousands of tiny decisions each second: where to move a character, when a bullet hits an enemy, how fast things should travel across the screen. Game engines like Unity and Godot hide all of that from you behind drag-and-drop interfaces.
In this project, you will build a twin-stick shooter from scratch using C++ and SDL3. You will write the game loop, the movement system, the projectile physics, and the collision detection yourself, with no engine doing the work for you.
What You'll Build
A top-down arena shooter compiled as a native macOS executable, where you move with WASD, aim with the mouse, and shoot bullets at enemies that chase you.
By the end of this project, you'll have:
- A native macOS executable running a real-time game with keyboard and mouse input.
- A game loop with delta time that keeps movement smooth regardless of frame rate.
- Core game systems built from scratch: projectiles with 2D vector math, enemy AI that chases you, and circle-circle collision detection.
- Secret Mission: Add a dash ability with cooldown and invincibility frames.
Are there any prerequisites?
You should be comfortable with basic C++ concepts: variables, functions, loops, structs, and pointers. You do not need any prior game development experience.
This project runs on macOS with Homebrew and Xcode Command Line Tools already installed. You will install SDL3 and CMake in Step 1.
Install SDL3 and Set Up the Build System
Every game starts with the same foundation: a window that appears on screen. Before you can render players, bullets, or enemies, you need a working build system that compiles your C++ code and links it to SDL3's rendering library.
In this step, you'll create a project folder, write a CMake build configuration, and prove the whole toolchain works by popping open an SDL3 window.
In this step, get ready to:
- Create the TwinStickShooter project folder with src/ and build/ directories.
- Write a CMakeLists.txt file that configures the build system.
- Compile and run a test program that opens an SDL3 window.
Create the project directory structure
You need a folder to hold your source code, build output, and configuration. The src/ directory will hold your C++ source files. The build/ directory is where CMake generates compiled binaries.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and its subdirectories by running these commands:
mkdir TwinStickShooter
cd TwinStickShooter
mkdir src build
- Confirm the folders exist by running:
ls
You should see build and src in the output.
Don't see both folders?
Make sure you ran each mkdir command from inside the TwinStickShooter directory. Run pwd to confirm your current location ends with TwinStickShooter.
help me figure out why my project directories weren't created
- Open the TwinStickShooter folder in your code editor.
- Create a new file in the project root called CMakeLists.txt and paste the following code:
# Require CMake 3.15 or higher
cmake_minimum_required(VERSION 3.15)
project(TwinStickShooter)
# Use C++17 standard
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Locate the SDL3 library installed via Homebrew
find_package(SDL3 REQUIRED)
# Build the executable from src/main.cpp and link SDL3
add_executable(${PROJECT_NAME} src/main.cpp)
target_link_libraries(${PROJECT_NAME} PRIVATE SDL3::SDL3)
What does this file do?
- cmake_minimum_required(VERSION 3.15) sets the minimum CMake version needed to build this project.
- find_package(SDL3 REQUIRED) tells CMake to search for SDL3's config files on your system. Since you installed SDL3 via Homebrew, CMake knows where to find it.
- target_link_libraries(... SDL3::SDL3) links the SDL3 library to your executable so your code can call SDL3 functions.
- Save CMakeLists.txt.
Write the hello window program
Your build file is ready but there's no source code to compile yet. You'll write a tiny program that initializes SDL3, opens a window for two seconds, and shuts down cleanly.
- Create a new file at src/main.cpp and paste the following code:
#include <SDL3/SDL.h>
int main(int argc, char* args[]) {
// Initialize SDL's video subsystem
if (!SDL_Init(SDL_INIT_VIDEO)) {
SDL_Log("Failed to initialize SDL: %s", SDL_GetError());
return 1;
}
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
// Create a window and renderer in one call
if (!SDL_CreateWindowAndRenderer("Twin-Stick Shooter", 800, 600, 0, &window, &renderer)) {
SDL_Log("Failed to create window/renderer: %s", SDL_GetError());
SDL_Quit();
return 1;
}
// Keep the window open for 2 seconds so you can see it
SDL_Delay(2000);
// Clean up resources before exiting
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}
What does this code do?
- SDL_Init(SDL_INIT_VIDEO) starts SDL3's video subsystem. In SDL3, this returns true on success, so !SDL_Init(...) catches failures.
- SDL_CreateWindowAndRenderer creates both a window and a renderer in a single call. The renderer is what you'll use later to draw shapes on screen.
- SDL_Delay(2000) pauses for 2000 milliseconds (2 seconds) so you can confirm the window actually appeared.
- SDL_DestroyRenderer and SDL_DestroyWindow free the resources, and SDL_Quit() shuts down SDL3 cleanly.
- Save src/main.cpp.
Build and run your game
With both files saved, you can now use CMake to generate a build system and compile your executable.
- Configure the project by running these commands from your TwinStickShooter directory:
cd build
cmake ..
What do these commands do?
cd build moves into the build directory so compiled files stay separate from your source code. cmake .. reads the CMakeLists.txt one level up and generates the actual Makefiles needed to compile.
You should see output ending with lines mentioning your compiler and a message like "Build files have been written to" followed by your build directory path.
Seeing an error about SDL3 not found?
CMake needs to locate SDL3's config files. Confirm SDL3 is installed by running brew list sdl3 in a separate terminal tab. If it's not listed, run brew install sdl3 and then re-run cmake ...
help me fix CMake not finding SDL3 on my Mac
- Compile the project by running:
cmake --build .
This compiles your C++ code and produces the TwinStickShooter executable inside the build/ directory.
Seeing a compilation error?
Check that src/main.cpp includes <SDL3/SDL.h> (not the old SDL2 path <SDL2/SDL.h>). Also verify that your main function signature uses int argc, char* args[].
help me fix my SDL3 compilation error
- Run your compiled game by executing:
./TwinStickShooter
A window titled "Twin-Stick Shooter" appears on screen for two seconds and then closes automatically. That confirms SDL3, CMake, and your compiler are all working together.
Window doesn't appear?
If you see a permission dialog asking about screen recording or input monitoring, click Allow. macOS sometimes prompts new graphical applications for permissions the first time they run.
If you get a "command not found" error, make sure you're inside the build/ directory. Run pwd to check.
help me get my SDL3 window to appear
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here's what your CMakeLists.txt should look like:
cmake_minimum_required(VERSION 3.15)
project(TwinStickShooter)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(SDL3 REQUIRED)
add_executable(${PROJECT_NAME} src/main.cpp)
target_link_libraries(${PROJECT_NAME} PRIVATE SDL3::SDL3)
And here's what src/main.cpp should look like:
#include <SDL3/SDL.h>
int main(int argc, char* args[]) {
if (!SDL_Init(SDL_INIT_VIDEO)) {
SDL_Log("Failed to initialize SDL: %s", SDL_GetError());
return 1;
}
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
if (!SDL_CreateWindowAndRenderer("Twin-Stick Shooter", 800, 600, 0, &window, &renderer)) {
SDL_Log("Failed to create window/renderer: %s", SDL_GetError());
SDL_Quit();
return 1;
}
SDL_Delay(2000);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}
Your toolchain is working. Next up, you'll build the game loop and render a movable player on screen.
Build the Game Loop and Render a Player
In Step 1 you proved your toolchain works by opening and closing an SDL3 window. Now it's time to make that window come alive.
Every game runs on a game loop. It's a cycle that repeats every frame: read input, update the world, draw everything. To keep movement smooth regardless of frame rate, you'll measure the time between frames using delta time.
In this step, get ready to:
- Define game constants and a Player struct to represent your character.
- Build a game loop that calculates delta time each frame.
- Render the player as a green rectangle on a dark background.
Create the Player struct and game constants
Before writing the game loop, you need to define what exists in your game world. Right now that's just the player, plus some constants that control the game's feel.
- Open src/main.cpp in your editor.
- Select all and delete the existing contents.
- Paste the following code at the top of the now-empty file:
#include <SDL3/SDL.h>
#include <vector>
#include <cmath>
#include <cstdlib>
#include <ctime>
// --- Constants ---
const int SCREEN_WIDTH = 800;
const int SCREEN_HEIGHT = 600;
const float PLAYER_SPEED = 300.0f;
const float BULLET_SPEED = 600.0f;
const float ENEMY_SPEED = 120.0f;
const float SPAWN_INTERVAL = 1.5f;
const int MAX_HEALTH = 5;
// --- Structs ---
struct Player {
float x, y;
float size;
float speed;
int health;
};
// --- Helper Functions ---
float length(float x, float y) {
return std::sqrt(x * x + y * y);
}
What does this code do?
The constants at the top define how the game feels. PLAYER_SPEED controls how fast the player moves in pixels per second. MAX_HEALTH sets how many hits you can take before the game ends.
The Player struct bundles position (x/y), size, speed, and health into one package. The length helper function calculates the magnitude of a 2D vector. You'll use this later for normalizing directions.
You won't see a change yet. The next chunk adds the main function that uses these definitions.
Implement the game loop and render the player
The game loop runs once per frame. Each iteration measures how much time has passed since the last frame (delta time) and uses that to keep movement speed consistent no matter how fast or slow the computer renders.
- Below the length function, paste the entire main function:
// --- Main ---
int main(int argc, char* args[]) {
if (!SDL_Init(SDL_INIT_VIDEO)) {
SDL_Log("Failed to initialize SDL: %s", SDL_GetError());
return 1;
}
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
if (!SDL_CreateWindowAndRenderer("Twin-Stick Shooter", SCREEN_WIDTH, SCREEN_HEIGHT, 0, &window, &renderer)) {
SDL_Log("Failed to create window/renderer: %s", SDL_GetError());
SDL_Quit();
return 1;
}
std::srand(static_cast<unsigned>(std::time(nullptr)));
Player player = {SCREEN_WIDTH / 2.0f, SCREEN_HEIGHT / 2.0f, 20.0f, PLAYER_SPEED, MAX_HEALTH};
int score = 0;
bool gameOver = false;
Uint64 lastTime = SDL_GetPerformanceCounter();
float frequency = static_cast<float>(SDL_GetPerformanceFrequency());
bool running = true;
while (running) {
Uint64 currentTime = SDL_GetPerformanceCounter();
float deltaTime = (currentTime - lastTime) / frequency;
lastTime = currentTime;
if (deltaTime > 0.05f) deltaTime = 0.05f;
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_QUIT) {
running = false;
}
}
// --- Render ---
SDL_SetRenderDrawColor(renderer, 20, 20, 30, 255);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, 0, 200, 100, 255);
SDL_FRect playerRect = {player.x - player.size / 2, player.y - player.size / 2, player.size, player.size};
SDL_RenderFillRect(renderer, &playerRect);
SDL_RenderPresent(renderer);
}
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}
What does this code do?
SDL_GetPerformanceCounter returns a high-resolution tick count. By measuring the difference between the current and previous frame's tick, then dividing by SDL_GetPerformanceFrequency, you get deltaTime in seconds.
The 0.05f cap prevents a "spiral of death". If the game freezes for a second (like while moving the window), delta time would spike and entities would teleport. The cap keeps the maximum simulated step at 50 milliseconds.
SDL_PollEvent drains the OS event queue each frame. When the user clicks the window's close button, SDL_EVENT_QUIT fires and the loop exits. The render section clears the screen to dark blue-grey, draws the player as a green filled rectangle, and presents the frame.
Why delta time instead of a fixed frame rate?
Without delta time, movement speed would depend on how fast the computer runs the loop. A faster machine would make the player zip around. A slower machine would make everything crawl.
Multiplying movement by deltaTime each frame guarantees that the player moves at PLAYER_SPEED pixels per second regardless of frame rate.
- Save src/main.cpp.
- Rebuild the project by running these commands from your TwinStickShooter folder:
cd build
cmake --build .
- Run the game:
./TwinStickShooter
You should see a small green square sitting in the center of a dark window. The window stays open until you close it.
Don't see the green square?
If the build fails with undefined reference errors, make sure both code chunks are in src/main.cpp and that the length function appears before main.
If the window opens and immediately closes, check that you removed the old SDL_Delay(2000) line. The game loop replaces that delay approach entirely.
Help me debug my game loop
✔️ Awesome, I've got everything!
Great. Double check you've saved src/main.cpp and the green square is visible in the center of your window.
ⓧ I'd like to double check the full code
Here's the complete src/main.cpp at the end of this step:
#include <SDL3/SDL.h>
#include <vector>
#include <cmath>
#include <cstdlib>
#include <ctime>
// --- Constants ---
const int SCREEN_WIDTH = 800;
const int SCREEN_HEIGHT = 600;
const float PLAYER_SPEED = 300.0f;
const float BULLET_SPEED = 600.0f;
const float ENEMY_SPEED = 120.0f;
const float SPAWN_INTERVAL = 1.5f;
const int MAX_HEALTH = 5;
// --- Structs ---
struct Player {
float x, y;
float size;
float speed;
int health;
};
// --- Helper Functions ---
float length(float x, float y) {
return std::sqrt(x * x + y * y);
}
// --- Main ---
int main(int argc, char* args[]) {
if (!SDL_Init(SDL_INIT_VIDEO)) {
SDL_Log("Failed to initialize SDL: %s", SDL_GetError());
return 1;
}
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
if (!SDL_CreateWindowAndRenderer("Twin-Stick Shooter", SCREEN_WIDTH, SCREEN_HEIGHT, 0, &window, &renderer)) {
SDL_Log("Failed to create window/renderer: %s", SDL_GetError());
SDL_Quit();
return 1;
}
std::srand(static_cast<unsigned>(std::time(nullptr)));
Player player = {SCREEN_WIDTH / 2.0f, SCREEN_HEIGHT / 2.0f, 20.0f, PLAYER_SPEED, MAX_HEALTH};
int score = 0;
bool gameOver = false;
Uint64 lastTime = SDL_GetPerformanceCounter();
float frequency = static_cast<float>(SDL_GetPerformanceFrequency());
bool running = true;
while (running) {
Uint64 currentTime = SDL_GetPerformanceCounter();
float deltaTime = (currentTime - lastTime) / frequency;
lastTime = currentTime;
if (deltaTime > 0.05f) deltaTime = 0.05f;
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_QUIT) {
running = false;
}
}
// --- Render ---
SDL_SetRenderDrawColor(renderer, 20, 20, 30, 255);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, 0, 200, 100, 255);
SDL_FRect playerRect = {player.x - player.size / 2, player.y - player.size / 2, player.size, player.size};
SDL_RenderFillRect(renderer, &playerRect);
SDL_RenderPresent(renderer);
}
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}
Your player exists and the game loop is running. Next up, you'll make that green square move with WASD and aim toward your mouse cursor.
Add WASD Movement and Mouse Aiming
Your player is on screen, but it just sits there. A twin-stick shooter needs two independent control axes: one for movement and one for aiming.
In this step, you'll read keyboard state every frame for WASD movement and track the mouse cursor to compute an aim direction. By the end, your green square will slide around the arena and point a grey line wherever you aim.
In this step, get ready to:
- Poll keyboard state for WASD movement with diagonal normalization.
- Query mouse position and compute the aim direction vector.
- Draw an aim indicator line from the player toward the cursor.
Poll keyboard state for WASD movement
SDL doesn't fire events for keys that are held down. Instead, it maintains a snapshot of every key's current state. You read that snapshot once per frame and move the player based on which keys are pressed.
- In src/main.cpp, find the length() helper function under // --- Helper Functions ---.
- Add this distance() function directly below length():
float distance(float x1, float y1, float x2, float y2) {
return length(x2 - x1, y2 - y1);
}
Why add this now?
You won't call distance() yet, but it builds on length() and you'll need it for collision detection in a later step. Adding it now keeps all your math helpers grouped together.
- Now scroll down inside the while (running) loop. Find the closing brace of the while (SDL_PollEvent(&event)) block.
- Add this input polling section directly below that closing brace:
// --- Input ---
const bool* keyState = SDL_GetKeyboardState(nullptr);
float moveX = 0.0f, moveY = 0.0f;
if (keyState[SDL_SCANCODE_W]) moveY -= 1.0f;
if (keyState[SDL_SCANCODE_S]) moveY += 1.0f;
if (keyState[SDL_SCANCODE_A]) moveX -= 1.0f;
if (keyState[SDL_SCANCODE_D]) moveX += 1.0f;
// Normalize diagonal movement
float moveLen = length(moveX, moveY);
if (moveLen > 0.0f) {
moveX /= moveLen;
moveY /= moveLen;
}
What does this code do?
- SDL_GetKeyboardState(nullptr) returns a pointer to an array of booleans. Each entry corresponds to a key's current state (pressed or not).
- You index into it with scancodes like SDL_SCANCODE_W to check if W is pressed this frame.
- Normalization prevents diagonal movement from being faster. Without it, pressing W+D would move at roughly 1.41x speed because both axes add up.
You won't see a change yet. The input is read but the player's position isn't updated. The next chunk handles that.
- Directly below the normalization code, add the player position update and screen clamping:
// --- Update Player ---
player.x += moveX * player.speed * deltaTime;
player.y += moveY * player.speed * deltaTime;
// Clamp to screen bounds
if (player.x < player.size / 2) player.x = player.size / 2;
if (player.x > SCREEN_WIDTH - player.size / 2) player.x = SCREEN_WIDTH - player.size / 2;
if (player.y < player.size / 2) player.y = player.size / 2;
if (player.y > SCREEN_HEIGHT - player.size / 2) player.y = SCREEN_HEIGHT - player.size / 2;
What does this code do?
- Multiplying by deltaTime makes movement frame-rate independent. On a fast machine (high FPS), each frame's deltaTime is smaller, so the player moves a tiny bit per frame but the same total distance per second.
- The clamping checks keep the player's center at least half its size away from each screen edge. This prevents the square from sliding off-screen.
- Save src/main.cpp.
- Rebuild and run your game by running these commands from your project's root folder:
cmake --build build
./build/TwinStickShooter
- Press W, A, S, and D. Your green square should slide smoothly around the window and stop at the edges.
Player not moving?
- Make sure the input section is placed AFTER the event polling loop and BEFORE the // --- Render --- section. If it's inside the event loop, it runs per-event instead of per-frame.
- Confirm you're multiplying by deltaTime. Without it, the position increments are tiny fractions that look like no movement.
- Check that the game window is focused. SDL only reads keyboard state when the window has focus.
Help me debug why WASD movement isn't working in my SDL3 game loop
Query mouse position for aim direction
The second stick in a twin-stick shooter is the mouse. Each frame, you'll read the cursor coordinates and compute a unit vector pointing from the player toward the mouse.
- In src/main.cpp, find the screen clamping code you just added. Add the mouse aim section directly below it (before the // --- Render --- comment):
// --- Mouse Aim ---
float mouseX, mouseY;
SDL_GetMouseState(&mouseX, &mouseY);
float aimDirX = mouseX - player.x;
float aimDirY = mouseY - player.y;
float aimLen = length(aimDirX, aimDirY);
if (aimLen > 0.0f) {
aimDirX /= aimLen;
aimDirY /= aimLen;
}
What does this code do?
- SDL_GetMouseState(&mouseX, &mouseY) writes the cursor's current pixel coordinates into the two float variables. In SDL3, these are floats (SDL2 used ints).
- Subtracting the player position from the mouse position gives a vector pointing from the player toward the cursor.
- Dividing by the vector's length normalizes it to a unit vector (length 1). This gives you a pure direction, regardless of how far the cursor is from the player.
You won't see a visible change yet. The aim direction is computed but not drawn. The next chunk draws it as a line.
Draw the aim indicator line
To give the player visual feedback on where they're aiming, you'll draw a short grey line extending from the player's center in the aim direction.
- In the // --- Render --- section, find the SDL_RenderFillRect(renderer, &playerRect); line.
- Add these two lines directly below it (before SDL_RenderPresent):
SDL_SetRenderDrawColor(renderer, 100, 100, 100, 255);
SDL_RenderLine(renderer, player.x, player.y, player.x + aimDirX * 40, player.y + aimDirY * 40);
What does this code do?
SDL_RenderLine draws a line between two points. The start is the player's center. The end is 40 pixels out from the player in the aim direction. The grey color (100, 100, 100) keeps it subtle so it doesn't distract from gameplay.
- Save src/main.cpp.
- Rebuild and run by running:
cmake --build build
./build/TwinStickShooter
- Move the player with WASD and move your mouse around the window. You should see a short grey line pointing from the green square toward your cursor at all times.
Don't see the aim line?
- Make sure the SDL_SetRenderDrawColor and SDL_RenderLine lines are AFTER the player rect is drawn and BEFORE SDL_RenderPresent. If they're after present, the line gets drawn to the back buffer that nobody sees.
- Check that aimDirX and aimDirY are declared in scope before the render section. If they're inside a separate block, the render section can't see them.
Help me debug why my SDL3 aim line isn't rendering
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's the complete src/main.cpp at this point. Compare carefully with yours.
#include <SDL3/SDL.h>
#include <vector>
#include <cmath>
#include <cstdlib>
#include <ctime>
// --- Constants ---
const int SCREEN_WIDTH = 800;
const int SCREEN_HEIGHT = 600;
const float PLAYER_SPEED = 300.0f;
const float BULLET_SPEED = 600.0f;
const float ENEMY_SPEED = 120.0f;
const float SPAWN_INTERVAL = 1.5f;
const int MAX_HEALTH = 5;
// --- Structs ---
struct Player {
float x, y;
float size;
float speed;
int health;
};
// --- Helper Functions ---
float length(float x, float y) {
return std::sqrt(x * x + y * y);
}
float distance(float x1, float y1, float x2, float y2) {
return length(x2 - x1, y2 - y1);
}
// --- Main ---
int main(int argc, char* args[]) {
if (!SDL_Init(SDL_INIT_VIDEO)) {
SDL_Log("Failed to initialize SDL: %s", SDL_GetError());
return 1;
}
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
if (!SDL_CreateWindowAndRenderer("Twin-Stick Shooter", SCREEN_WIDTH, SCREEN_HEIGHT, 0, &window, &renderer)) {
SDL_Log("Failed to create window/renderer: %s", SDL_GetError());
SDL_Quit();
return 1;
}
std::srand(static_cast<unsigned>(std::time(nullptr)));
Player player = {SCREEN_WIDTH / 2.0f, SCREEN_HEIGHT / 2.0f, 20.0f, PLAYER_SPEED, MAX_HEALTH};
int score = 0;
bool gameOver = false;
Uint64 lastTime = SDL_GetPerformanceCounter();
float frequency = static_cast<float>(SDL_GetPerformanceFrequency());
bool running = true;
while (running) {
Uint64 currentTime = SDL_GetPerformanceCounter();
float deltaTime = (currentTime - lastTime) / frequency;
lastTime = currentTime;
if (deltaTime > 0.05f) deltaTime = 0.05f;
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_QUIT) {
running = false;
}
}
// --- Input ---
const bool* keyState = SDL_GetKeyboardState(nullptr);
float moveX = 0.0f, moveY = 0.0f;
if (keyState[SDL_SCANCODE_W]) moveY -= 1.0f;
if (keyState[SDL_SCANCODE_S]) moveY += 1.0f;
if (keyState[SDL_SCANCODE_A]) moveX -= 1.0f;
if (keyState[SDL_SCANCODE_D]) moveX += 1.0f;
float moveLen = length(moveX, moveY);
if (moveLen > 0.0f) {
moveX /= moveLen;
moveY /= moveLen;
}
// --- Update Player ---
player.x += moveX * player.speed * deltaTime;
player.y += moveY * player.speed * deltaTime;
if (player.x < player.size / 2) player.x = player.size / 2;
if (player.x > SCREEN_WIDTH - player.size / 2) player.x = SCREEN_WIDTH - player.size / 2;
if (player.y < player.size / 2) player.y = player.size / 2;
if (player.y > SCREEN_HEIGHT - player.size / 2) player.y = SCREEN_HEIGHT - player.size / 2;
// --- Mouse Aim ---
float mouseX, mouseY;
SDL_GetMouseState(&mouseX, &mouseY);
float aimDirX = mouseX - player.x;
float aimDirY = mouseY - player.y;
float aimLen = length(aimDirX, aimDirY);
if (aimLen > 0.0f) {
aimDirX /= aimLen;
aimDirY /= aimLen;
}
// --- Render ---
SDL_SetRenderDrawColor(renderer, 20, 20, 30, 255);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, 0, 200, 100, 255);
SDL_FRect playerRect = {player.x - player.size / 2, player.y - player.size / 2, player.size, player.size};
SDL_RenderFillRect(renderer, &playerRect);
SDL_SetRenderDrawColor(renderer, 100, 100, 100, 255);
SDL_RenderLine(renderer, player.x, player.y, player.x + aimDirX * 40, player.y + aimDirY * 40);
SDL_RenderPresent(renderer);
}
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}
Your player can now move and aim independently. Next up, you'll turn that aim direction into actual bullets that fly across the screen.
Implement the Bullet System
Your player can move and aim, but without projectiles there is no way to fight back against the enemies you will add in the next step.
In this step you will build a complete bullet system. You will detect mouse clicks, spawn bullets that travel toward the cursor, move them every frame using the same delta-time pattern you already know, and clean up bullets that fly off screen.
In this step, get ready to:
- Create a Bullet struct to hold projectile data.
- Spawn a bullet toward the cursor on left mouse click.
- Update bullet positions each frame and remove off-screen bullets.
Create the Bullet struct
Each bullet needs to know where it is, which direction it is traveling, how big it is, and whether it is still active. A struct bundles all of those fields together so you can manage many bullets in a list.
- In src/main.cpp, find the closing brace of the Player struct.
- Add the following Bullet struct directly below it:
struct Bullet {
float x, y; // current position
float vx, vy; // velocity (direction * speed)
float radius; // collision and render size
bool alive; // false = pending removal
};
What does this struct do?
Each bullet stores its own position, velocity, size, and whether it is still alive. The alive flag lets you mark a bullet for removal without deleting it mid-loop.
- Next, scroll down to the game state section inside main(). Find this line:
int score = 0;
- Add the bullet list on the line directly above it, so you have:
std::vector<Bullet> bullets;
int score = 0;
Why a vector?
A std::vector is a dynamic array. Bullets are created and destroyed constantly, so you need a container that can grow and shrink at runtime.
- Save src/main.cpp.
Detect mouse clicks and spawn bullets
Keyboard state is polled every frame, but mouse clicks are one-shot events. You need to catch the click inside the event loop and then use it later in the update phase to spawn a bullet.
- In src/main.cpp, find the event handling section inside the game loop. It currently looks like this:
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_QUIT) {
running = false;
}
}
- Replace that entire block with the following code, which adds a shootThisFrame flag and detects left mouse button clicks:
SDL_Event event;
bool shootThisFrame = false;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_QUIT) {
running = false;
}
if (event.type == SDL_EVENT_MOUSE_BUTTON_DOWN && event.button.button == SDL_BUTTON_LEFT) {
shootThisFrame = true;
}
}
What does this code do?
The shootThisFrame boolean is reset to false every frame. If a left-click event fires during this frame's event poll, the flag flips to true.
The check uses SDL_EVENT_MOUSE_BUTTON_DOWN to detect the press and SDL_BUTTON_LEFT to make sure it is the left button specifically.
Now you need to actually spawn a bullet when the flag is set. The bullet should start at the player's position and fly toward the cursor.
- Scroll down to the // --- Mouse Aim --- section. After the aim direction normalization block (the closing brace of if (aimLen > 0.0f)), add the following shoot section:
// --- Shoot ---
if (shootThisFrame) {
Bullet b;
b.x = player.x;
b.y = player.y;
b.vx = aimDirX * BULLET_SPEED;
b.vy = aimDirY * BULLET_SPEED;
b.radius = 5.0f;
b.alive = true;
bullets.push_back(b);
}
What does this code do?
When the player clicks, a new Bullet is created at the player's center. Its velocity is the normalized aim direction multiplied by BULLET_SPEED (600 pixels per second).
The bullet is pushed into the bullets vector. From here on, the game loop will move and draw it every frame.
- Save src/main.cpp.
Seeing a compile error about SDL_BUTTON_LEFT?
Make sure you are checking event.button.button (two uses of "button"). The first accesses the button-event union member. The second accesses which button was pressed.
Also confirm the event type check uses SDL_EVENT_MOUSE_BUTTON_DOWN (SDL3 naming), not the SDL2-era SDL_MOUSEBUTTONDOWN.
help me fix a compile error related to mouse click detection in SDL3
Update bullet positions and remove off-screen bullets
Spawning alone is not enough. Each bullet must move every frame. Bullets that fly off screen should be cleaned up so the vector does not grow forever.
- In src/main.cpp, directly below the // --- Shoot --- block you just added, paste the update and removal logic:
// --- Update Bullets ---
for (auto& b : bullets) {
if (!b.alive) continue;
b.x += b.vx * deltaTime;
b.y += b.vy * deltaTime;
// Remove if off screen
if (b.x < -50 || b.x > SCREEN_WIDTH + 50 || b.y < -50 || b.y > SCREEN_HEIGHT + 50) {
b.alive = false;
}
}
// --- Remove dead bullets ---
bullets.erase(
std::remove_if(bullets.begin(), bullets.end(), [](const Bullet& b) { return !b.alive; }),
bullets.end());
What does this code do?
The update loop moves each living bullet by its velocity scaled by deltaTime. This keeps bullet speed consistent regardless of frame rate.
Bullets that go more than 50 pixels past any screen edge are flagged alive = false. The 50-pixel margin prevents visual popping if a bullet is partly visible at the edge.
The erase-remove idiom (std::remove_if followed by erase) compacts the vector, physically removing dead bullets so memory stays clean.
The last piece is drawing the bullets on screen. You will render each one as a small yellow filled rectangle.
- Scroll down to the // --- Render --- section. Find the aim line draw call (the SDL_RenderLine line). Directly below it, add:
// Draw bullets
SDL_SetRenderDrawColor(renderer, 255, 255, 0, 255);
for (const auto& b : bullets) {
SDL_FRect bulletRect = {b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2};
SDL_RenderFillRect(renderer, &bulletRect);
}
What does this code do?
The draw color is set to yellow (255, 255, 0). Each bullet is drawn as a filled square centered on its position, with side length equal to radius * 2. At 5 pixels radius, each bullet appears as a 10x10 yellow square.
- Save src/main.cpp.
- Rebuild and run your game by running these commands from your build directory:
cmake --build .
./TwinStickShooter
- Move around with WASD, then left-click. You should see small yellow squares flying from your player toward the cursor.
Bullets not appearing on click?
Make sure the bool shootThisFrame = false; line sits before the while (SDL_PollEvent(&event)) loop, not inside it. If declared inside, the flag resets every event.
Also check that your bullet rendering code is placed after the aim line draw and before SDL_RenderPresent. Drawing after present means it will not show until the next frame.
help me debug why my bullets aren't showing when I click
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.cpp before moving on.
ⓧ I'd like to double check the full code
#include <SDL3/SDL.h>
#include <vector>
#include <cmath>
#include <cstdlib>
#include <ctime>
// --- Constants ---
const int SCREEN_WIDTH = 800;
const int SCREEN_HEIGHT = 600;
const float PLAYER_SPEED = 300.0f;
const float BULLET_SPEED = 600.0f;
const float ENEMY_SPEED = 120.0f;
const float SPAWN_INTERVAL = 1.5f;
const int MAX_HEALTH = 5;
// --- Structs ---
struct Player {
float x, y;
float size;
float speed;
int health;
};
struct Bullet {
float x, y;
float vx, vy;
float radius;
bool alive;
};
// --- Helper Functions ---
float length(float x, float y) {
return std::sqrt(x * x + y * y);
}
float distance(float x1, float y1, float x2, float y2) {
return length(x2 - x1, y2 - y1);
}
// --- Main ---
int main(int argc, char* args[]) {
if (!SDL_Init(SDL_INIT_VIDEO)) {
SDL_Log("Failed to initialize SDL: %s", SDL_GetError());
return 1;
}
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
if (!SDL_CreateWindowAndRenderer("Twin-Stick Shooter", SCREEN_WIDTH, SCREEN_HEIGHT, 0, &window, &renderer)) {
SDL_Log("Failed to create window/renderer: %s", SDL_GetError());
SDL_Quit();
return 1;
}
std::srand(static_cast<unsigned>(std::time(nullptr)));
Player player = {SCREEN_WIDTH / 2.0f, SCREEN_HEIGHT / 2.0f, 20.0f, PLAYER_SPEED, MAX_HEALTH};
std::vector<Bullet> bullets;
int score = 0;
bool gameOver = false;
Uint64 lastTime = SDL_GetPerformanceCounter();
float frequency = static_cast<float>(SDL_GetPerformanceFrequency());
bool running = true;
while (running) {
Uint64 currentTime = SDL_GetPerformanceCounter();
float deltaTime = (currentTime - lastTime) / frequency;
lastTime = currentTime;
if (deltaTime > 0.05f) deltaTime = 0.05f;
SDL_Event event;
bool shootThisFrame = false;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_QUIT) {
running = false;
}
if (event.type == SDL_EVENT_MOUSE_BUTTON_DOWN && event.button.button == SDL_BUTTON_LEFT) {
shootThisFrame = true;
}
}
// --- Input ---
const bool* keyState = SDL_GetKeyboardState(nullptr);
float moveX = 0.0f, moveY = 0.0f;
if (keyState[SDL_SCANCODE_W]) moveY -= 1.0f;
if (keyState[SDL_SCANCODE_S]) moveY += 1.0f;
if (keyState[SDL_SCANCODE_A]) moveX -= 1.0f;
if (keyState[SDL_SCANCODE_D]) moveX += 1.0f;
float moveLen = length(moveX, moveY);
if (moveLen > 0.0f) {
moveX /= moveLen;
moveY /= moveLen;
}
// --- Update Player ---
player.x += moveX * player.speed * deltaTime;
player.y += moveY * player.speed * deltaTime;
if (player.x < player.size / 2) player.x = player.size / 2;
if (player.x > SCREEN_WIDTH - player.size / 2) player.x = SCREEN_WIDTH - player.size / 2;
if (player.y < player.size / 2) player.y = player.size / 2;
if (player.y > SCREEN_HEIGHT - player.size / 2) player.y = SCREEN_HEIGHT - player.size / 2;
// --- Mouse Aim ---
float mouseX, mouseY;
SDL_GetMouseState(&mouseX, &mouseY);
float aimDirX = mouseX - player.x;
float aimDirY = mouseY - player.y;
float aimLen = length(aimDirX, aimDirY);
if (aimLen > 0.0f) {
aimDirX /= aimLen;
aimDirY /= aimLen;
}
// --- Shoot ---
if (shootThisFrame) {
Bullet b;
b.x = player.x;
b.y = player.y;
b.vx = aimDirX * BULLET_SPEED;
b.vy = aimDirY * BULLET_SPEED;
b.radius = 5.0f;
b.alive = true;
bullets.push_back(b);
}
// --- Update Bullets ---
for (auto& b : bullets) {
if (!b.alive) continue;
b.x += b.vx * deltaTime;
b.y += b.vy * deltaTime;
if (b.x < -50 || b.x > SCREEN_WIDTH + 50 || b.y < -50 || b.y > SCREEN_HEIGHT + 50) {
b.alive = false;
}
}
// --- Remove dead bullets ---
bullets.erase(
std::remove_if(bullets.begin(), bullets.end(), [](const Bullet& b) { return !b.alive; }),
bullets.end());
// --- Render ---
SDL_SetRenderDrawColor(renderer, 20, 20, 30, 255);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, 0, 200, 100, 255);
SDL_FRect playerRect = {player.x - player.size / 2, player.y - player.size / 2, player.size, player.size};
SDL_RenderFillRect(renderer, &playerRect);
SDL_SetRenderDrawColor(renderer, 100, 100, 100, 255);
SDL_RenderLine(renderer, player.x, player.y, player.x + aimDirX * 40, player.y + aimDirY * 40);
SDL_SetRenderDrawColor(renderer, 255, 255, 0, 255);
for (const auto& b : bullets) {
SDL_FRect bulletRect = {b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2};
SDL_RenderFillRect(renderer, &bulletRect);
}
SDL_RenderPresent(renderer);
}
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}
Your player can now fight back. Next up, you will give it something to fight. You will spawn enemies that chase the player and set up the collision system that ties everything together.
Spawn Enemies and Give Them Chase AI
You have a player that moves, aims, and shoots. But right now there's nothing to shoot at. A game needs adversaries that create pressure and force you to react.
In this step, you'll create enemies that spawn from the edges of the screen and relentlessly chase the player. The chase behavior uses the same 2D vector normalization you already wrote for aiming.
In this step, get ready to:
- Create an Enemy struct to represent each enemy.
- Spawn enemies at random screen edges on a timer.
- Move enemies toward the player each frame using normalized direction vectors.
Create the Enemy struct
Each enemy needs a position, a collision radius, a movement speed, and a flag to track whether it's still alive. This mirrors the pattern you used for bullets.
- In src/main.cpp, find the closing }; of the Bullet struct.
- Add the Enemy struct directly below it:
struct Enemy {
float x, y;
float radius;
float speed;
bool alive;
};
What does this struct do?
The Enemy struct holds everything the game needs to manage a single enemy.
- x, y stores the enemy's position in the world.
- radius defines the collision boundary (used for hit detection later).
- alive lets you mark an enemy for removal without deleting it mid-loop.
- Save src/main.cpp.
Spawn enemies from screen edges on a timer
Enemies should appear periodically from off-screen so the player always has something to fight. A simple timer accumulates delta time each frame, and when it exceeds the spawn interval, a new enemy appears at a random edge.
- First, add the enemy vector and spawn timer to your game state. Find this line:
std::vector<Bullet> bullets;
- Add the enemies vector directly below it:
std::vector<Enemy> enemies;
- Next, find this line:
bool gameOver = false;
- Add the spawn timer directly below it:
float spawnTimer = 0.0f;
- Now add the spawn logic. Find the closing brace of the // --- Update Bullets --- section (the end of the bullet bounds-checking loop). Add this spawn system below it:
// --- Spawn Enemies ---
spawnTimer += deltaTime;
if (spawnTimer >= SPAWN_INTERVAL) {
spawnTimer = 0.0f;
Enemy e;
e.radius = 15.0f;
e.speed = ENEMY_SPEED;
e.alive = true;
int edge = std::rand() % 4;
switch (edge) {
case 0: e.x = static_cast<float>(std::rand() % SCREEN_WIDTH); e.y = -e.radius; break;
case 1: e.x = static_cast<float>(std::rand() % SCREEN_WIDTH); e.y = SCREEN_HEIGHT + e.radius; break;
case 2: e.x = -e.radius; e.y = static_cast<float>(std::rand() % SCREEN_HEIGHT); break;
case 3: e.x = SCREEN_WIDTH + e.radius; e.y = static_cast<float>(std::rand() % SCREEN_HEIGHT); break;
}
enemies.push_back(e);
}
What does this code do?
- spawnTimer += deltaTime accumulates real elapsed time. When it reaches SPAWN_INTERVAL (1.5 seconds), a new enemy is created and the timer resets.
- std::rand() % 4 picks one of four screen edges (top, bottom, left, right). The enemy spawns just outside the visible area at a random position along that edge.
- Spawning slightly off-screen (at -e.radius or SCREEN_HEIGHT + e.radius) means enemies slide into view smoothly rather than popping in.
- Save src/main.cpp.
You won't see enemies yet because there's no rendering or movement code for them. The next chunk adds both.
Move enemies toward the player each frame
Chase AI sounds complex, but the math is identical to what you used for bullets. Calculate the direction from the enemy to the player, normalize it, then move along that direction each frame.
- Add the enemy update loop directly below the Spawn Enemies section you just wrote:
// --- Update Enemies ---
for (auto& e : enemies) {
if (!e.alive) continue;
float dirX = player.x - e.x;
float dirY = player.y - e.y;
float dirLen = length(dirX, dirY);
if (dirLen > 0.0f) {
dirX /= dirLen;
dirY /= dirLen;
}
e.x += dirX * e.speed * deltaTime;
e.y += dirY * e.speed * deltaTime;
}
How does the chase AI work?
Each frame, the code calculates a direction vector from the enemy to the player (player.x - e.x, player.y - e.y). Dividing by the vector's length normalizes it to a unit vector (length of 1).
Multiplying the unit direction by e.speed * deltaTime moves the enemy a consistent distance per second toward the player, regardless of frame rate.
- Now update the removal section. Find this comment:
// --- Remove dead bullets ---
- Rename it to // --- Remove dead entities --- and add the enemy cleanup below the existing bullets.erase call, so the full section looks like this:
// --- Remove dead entities ---
bullets.erase(
std::remove_if(bullets.begin(), bullets.end(), [](const Bullet& b) { return !b.alive; }),
bullets.end());
enemies.erase(
std::remove_if(enemies.begin(), enemies.end(), [](const Enemy& e) { return !e.alive; }),
enemies.end());
- Finally, add the enemy rendering code. Find the bullet rendering loop (the for (const auto& b : bullets) block). Add this directly after its closing brace:
// Draw enemies as red squares
SDL_SetRenderDrawColor(renderer, 255, 50, 50, 255);
for (const auto& e : enemies) {
SDL_FRect enemyRect = {e.x - e.radius, e.y - e.radius, e.radius * 2, e.radius * 2};
SDL_RenderFillRect(renderer, &enemyRect);
}
What does this rendering code do?
- SDL_SetRenderDrawColor(renderer, 255, 50, 50, 255) sets the draw color to red so enemies are visually distinct from the green player and yellow bullets.
- Each enemy is drawn as a filled square using its radius to determine width and height, matching the same pattern used for bullets.
- Save src/main.cpp.
- Rebuild and run your game by running:
cd build && cmake .. && make && ./TwinStickShooter
- Wait a few seconds. You should see red squares appearing from the edges of the screen and slowly chasing your green player square.
Don't see any enemies appearing?
- Make sure float spawnTimer = 0.0f; is declared outside the game loop (in the game state section, not inside while (running)). If it resets every frame, it never reaches the spawn interval.
- Check that the // --- Spawn Enemies --- section is inside the game loop, after the bullet update. If it's outside the loop, it only runs once.
- Verify the rendering block for enemies is before SDL_RenderPresent(renderer);. Anything drawn after the present call won't appear on screen.
Why aren't enemies spawning or showing up in my game?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
#include <SDL3/SDL.h>
#include <vector>
#include <cmath>
#include <cstdlib>
#include <ctime>
// --- Constants ---
const int SCREEN_WIDTH = 800;
const int SCREEN_HEIGHT = 600;
const float PLAYER_SPEED = 300.0f;
const float BULLET_SPEED = 600.0f;
const float ENEMY_SPEED = 120.0f;
const float SPAWN_INTERVAL = 1.5f;
const int MAX_HEALTH = 5;
// --- Structs ---
struct Player {
float x, y;
float size;
float speed;
int health;
};
struct Bullet {
float x, y;
float vx, vy;
float radius;
bool alive;
};
struct Enemy {
float x, y;
float radius;
float speed;
bool alive;
};
// --- Helper Functions ---
float length(float x, float y) {
return std::sqrt(x * x + y * y);
}
float distance(float x1, float y1, float x2, float y2) {
return length(x2 - x1, y2 - y1);
}
// --- Main ---
int main(int argc, char* args[]) {
if (!SDL_Init(SDL_INIT_VIDEO)) {
SDL_Log("Failed to initialize SDL: %s", SDL_GetError());
return 1;
}
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
if (!SDL_CreateWindowAndRenderer("Twin-Stick Shooter", SCREEN_WIDTH, SCREEN_HEIGHT, 0, &window, &renderer)) {
SDL_Log("Failed to create window/renderer: %s", SDL_GetError());
SDL_Quit();
return 1;
}
std::srand(static_cast<unsigned>(std::time(nullptr)));
Player player = {SCREEN_WIDTH / 2.0f, SCREEN_HEIGHT / 2.0f, 20.0f, PLAYER_SPEED, MAX_HEALTH};
std::vector<Bullet> bullets;
std::vector<Enemy> enemies;
int score = 0;
bool gameOver = false;
float spawnTimer = 0.0f;
Uint64 lastTime = SDL_GetPerformanceCounter();
float frequency = static_cast<float>(SDL_GetPerformanceFrequency());
bool running = true;
while (running) {
Uint64 currentTime = SDL_GetPerformanceCounter();
float deltaTime = (currentTime - lastTime) / frequency;
lastTime = currentTime;
if (deltaTime > 0.05f) deltaTime = 0.05f;
SDL_Event event;
bool shootThisFrame = false;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_QUIT) {
running = false;
}
if (event.type == SDL_EVENT_MOUSE_BUTTON_DOWN && event.button.button == SDL_BUTTON_LEFT) {
shootThisFrame = true;
}
}
// --- Input ---
const bool* keyState = SDL_GetKeyboardState(nullptr);
float moveX = 0.0f, moveY = 0.0f;
if (keyState[SDL_SCANCODE_W]) moveY -= 1.0f;
if (keyState[SDL_SCANCODE_S]) moveY += 1.0f;
if (keyState[SDL_SCANCODE_A]) moveX -= 1.0f;
if (keyState[SDL_SCANCODE_D]) moveX += 1.0f;
float moveLen = length(moveX, moveY);
if (moveLen > 0.0f) {
moveX /= moveLen;
moveY /= moveLen;
}
// --- Update Player ---
player.x += moveX * player.speed * deltaTime;
player.y += moveY * player.speed * deltaTime;
if (player.x < player.size / 2) player.x = player.size / 2;
if (player.x > SCREEN_WIDTH - player.size / 2) player.x = SCREEN_WIDTH - player.size / 2;
if (player.y < player.size / 2) player.y = player.size / 2;
if (player.y > SCREEN_HEIGHT - player.size / 2) player.y = SCREEN_HEIGHT - player.size / 2;
// --- Mouse Aim ---
float mouseX, mouseY;
SDL_GetMouseState(&mouseX, &mouseY);
float aimDirX = mouseX - player.x;
float aimDirY = mouseY - player.y;
float aimLen = length(aimDirX, aimDirY);
if (aimLen > 0.0f) {
aimDirX /= aimLen;
aimDirY /= aimLen;
}
// --- Shoot ---
if (shootThisFrame) {
Bullet b;
b.x = player.x;
b.y = player.y;
b.vx = aimDirX * BULLET_SPEED;
b.vy = aimDirY * BULLET_SPEED;
b.radius = 5.0f;
b.alive = true;
bullets.push_back(b);
}
// --- Update Bullets ---
for (auto& b : bullets) {
if (!b.alive) continue;
b.x += b.vx * deltaTime;
b.y += b.vy * deltaTime;
if (b.x < -50 || b.x > SCREEN_WIDTH + 50 || b.y < -50 || b.y > SCREEN_HEIGHT + 50) {
b.alive = false;
}
}
// --- Spawn Enemies ---
spawnTimer += deltaTime;
if (spawnTimer >= SPAWN_INTERVAL) {
spawnTimer = 0.0f;
Enemy e;
e.radius = 15.0f;
e.speed = ENEMY_SPEED;
e.alive = true;
int edge = std::rand() % 4;
switch (edge) {
case 0: e.x = static_cast<float>(std::rand() % SCREEN_WIDTH); e.y = -e.radius; break;
case 1: e.x = static_cast<float>(std::rand() % SCREEN_WIDTH); e.y = SCREEN_HEIGHT + e.radius; break;
case 2: e.x = -e.radius; e.y = static_cast<float>(std::rand() % SCREEN_HEIGHT); break;
case 3: e.x = SCREEN_WIDTH + e.radius; e.y = static_cast<float>(std::rand() % SCREEN_HEIGHT); break;
}
enemies.push_back(e);
}
// --- Update Enemies ---
for (auto& e : enemies) {
if (!e.alive) continue;
float dirX = player.x - e.x;
float dirY = player.y - e.y;
float dirLen = length(dirX, dirY);
if (dirLen > 0.0f) {
dirX /= dirLen;
dirY /= dirLen;
}
e.x += dirX * e.speed * deltaTime;
e.y += dirY * e.speed * deltaTime;
}
// --- Remove dead entities ---
bullets.erase(
std::remove_if(bullets.begin(), bullets.end(), [](const Bullet& b) { return !b.alive; }),
bullets.end());
enemies.erase(
std::remove_if(enemies.begin(), enemies.end(), [](const Enemy& e) { return !e.alive; }),
enemies.end());
// --- Render ---
SDL_SetRenderDrawColor(renderer, 20, 20, 30, 255);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, 0, 200, 100, 255);
SDL_FRect playerRect = {player.x - player.size / 2, player.y - player.size / 2, player.size, player.size};
SDL_RenderFillRect(renderer, &playerRect);
SDL_SetRenderDrawColor(renderer, 100, 100, 100, 255);
SDL_RenderLine(renderer, player.x, player.y, player.x + aimDirX * 40, player.y + aimDirY * 40);
SDL_SetRenderDrawColor(renderer, 255, 255, 0, 255);
for (const auto& b : bullets) {
SDL_FRect bulletRect = {b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2};
SDL_RenderFillRect(renderer, &bulletRect);
}
SDL_SetRenderDrawColor(renderer, 255, 50, 50, 255);
for (const auto& e : enemies) {
SDL_FRect enemyRect = {e.x - e.radius, e.y - e.radius, e.radius * 2, e.radius * 2};
SDL_RenderFillRect(renderer, &enemyRect);
}
SDL_RenderPresent(renderer);
}
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}
Your arena is getting dangerous. Enemies appear, chase, and overwhelm you. Next up, you'll add collision detection so your bullets can actually destroy enemies and enemies can damage your player.
Add Collision Detection and Scoring
Your enemies swarm toward the player, but right now they pass harmlessly through everything. Bullets fly through enemies without effect. The game has no stakes.
In this step, you will add the physics that makes this a real game. Circle-circle collision detection determines when objects overlap. When a bullet hits an enemy, both are destroyed. When an enemy reaches the player, health decreases. When health hits zero, the game is over.
In this step, get ready to:
- Implement circle-circle collision between bullets and enemies.
- Detect enemy-player collisions that reduce health and trigger game over.
- Add a game-over screen, restart key, and health bar display.
Implement bullet-vs-enemy collision
Two circles overlap when the distance between their centers is less than the sum of their radii. This single check powers all the collision detection in your game.
- In src/main.cpp, find the distance() function near the top of the file.
- Add the following circleCollision() helper function directly below it:
bool circleCollision(float x1, float y1, float r1, float x2, float y2, float r2) {
return distance(x1, y1, x2, y2) < (r1 + r2);
}
What does this function do?
It calculates the distance between two circle centers and returns true if that distance is smaller than the two radii combined. If so, the circles are overlapping.
- Now scroll down inside the game loop. Find the // --- Update Enemies --- section and its for loop.
- Add the following collision loop between the Update Enemies section and the // --- Remove dead entities --- section:
// --- Collision: Bullets vs Enemies ---
for (auto& b : bullets) {
if (!b.alive) continue;
for (auto& e : enemies) {
if (!e.alive) continue;
if (circleCollision(b.x, b.y, b.radius, e.x, e.y, e.radius)) {
b.alive = false;
e.alive = false;
score++;
break;
}
}
}
What does this code do?
The nested loop checks every living bullet against every living enemy. When circleCollision() returns true, both the bullet and the enemy are marked dead. The score variable increments by one.
The break exits the inner loop so that one bullet can only destroy one enemy per frame.
- Save src/main.cpp.
- You won't see a visible change yet. The next chunk adds player damage so you can feel the collisions from both sides.
Seeing a compile error?
Make sure circleCollision() is defined above main(), after the distance() function. C++ requires functions to be declared before they are called.
Also check that the collision loop is placed inside the while (running) game loop, not outside it.
Why won't my collision code compile?
Detect enemy-player collisions
Bullets can now destroy enemies, but enemies still pass through the player. You need a second collision check that damages the player when an enemy reaches them.
- In src/main.cpp, find the bullet-vs-enemy collision loop you just added.
- Add the following code directly below it (still above // --- Remove dead entities ---):
// --- Collision: Enemies vs Player ---
for (auto& e : enemies) {
if (!e.alive) continue;
if (circleCollision(player.x, player.y, player.size / 2, e.x, e.y, e.radius)) {
e.alive = false;
player.health--;
if (player.health <= 0) {
gameOver = true;
}
}
}
What does this code do?
This loop checks each living enemy against the player. The player's collision radius is player.size / 2 (half the square's width, treating it roughly as a circle).
When an enemy touches the player, the enemy dies, player.health decreases by one, and if health reaches zero, gameOver is set to true.
- Save src/main.cpp.
- You won't see the game-over effect yet. The next chunk adds the game-over screen and restart logic.
Add the game-over screen and health bar
The collision logic sets gameOver = true when health runs out, but nothing happens visually. You need a game-over screen, a way to restart, and a health bar so the player knows how much life remains.
- In src/main.cpp, find the event-handling while (SDL_PollEvent(&event)) loop.
- Find the if block that checks for SDL_EVENT_MOUSE_BUTTON_DOWN. Add the following code directly below it (still inside the poll-event while loop):
if (event.type == SDL_EVENT_KEY_DOWN && event.key.scancode == SDL_SCANCODE_R && gameOver) {
// Reset game
player = {SCREEN_WIDTH / 2.0f, SCREEN_HEIGHT / 2.0f, 20.0f, PLAYER_SPEED, MAX_HEALTH};
bullets.clear();
enemies.clear();
score = 0;
gameOver = false;
spawnTimer = 0.0f;
}
What does this code do?
When the R key is pressed and the game is over, this resets all game state back to the beginning. The player is re-centered, all bullets and enemies are cleared, and score and spawnTimer return to zero.
- Now find the closing brace of the while (SDL_PollEvent(&event)) loop.
- Add the following game-over render block directly below it (before the // --- Input --- section):
if (gameOver) {
// --- Render Game Over ---
SDL_SetRenderDrawColor(renderer, 40, 0, 0, 255);
SDL_RenderClear(renderer);
// Draw "GAME OVER" as a red rectangle indicator
SDL_SetRenderDrawColor(renderer, 255, 0, 0, 255);
SDL_FRect goRect = {SCREEN_WIDTH / 2.0f - 100, SCREEN_HEIGHT / 2.0f - 20, 200, 40};
SDL_RenderFillRect(renderer, &goRect);
SDL_RenderPresent(renderer);
continue;
}
What does this code do?
When gameOver is true, the loop skips all game logic (movement, shooting, collisions) and renders a dark red screen with a red rectangle in the center as a game-over indicator.
The continue statement jumps back to the top of the while loop. This means the event handling still runs (so the R key can restart the game), but nothing else updates.
- Finally, scroll to the bottom of the // --- Render --- section. Find the enemy-drawing for loop.
- Add the following health bar code directly below the enemy-drawing loop (before SDL_RenderPresent(renderer)):
// Draw health bar
SDL_SetRenderDrawColor(renderer, 80, 80, 80, 255);
SDL_FRect healthBg = {10, 10, 100, 15};
SDL_RenderFillRect(renderer, &healthBg);
SDL_SetRenderDrawColor(renderer, 0, 255, 0, 255);
float healthWidth = 100.0f * (static_cast<float>(player.health) / MAX_HEALTH);
SDL_FRect healthFg = {10, 10, healthWidth, 15};
SDL_RenderFillRect(renderer, &healthFg);
What does this code do?
The health bar is two rectangles layered on top of each other in the top-left corner. The grey background is always 100 pixels wide. The green foreground width is scaled by player.health / MAX_HEALTH, so it shrinks as the player takes damage.
- Save src/main.cpp.
- Rebuild and run your game by running these commands:
cd build && cmake .. && make && ./TwinStickShooter
- You should see a green health bar in the top-left corner. Shoot enemies with the mouse to see them disappear. Let an enemy reach you and watch the health bar shrink. When health hits zero, the screen turns dark red. Press R to restart.
Game crashes or health bar doesn't appear?
Make sure the health bar code is inside the if (gameOver) { ... continue; } block's ELSE path. If you accidentally placed it inside the game-over block, it will never render during normal gameplay.
If the R key does not restart the game, check that the reset code is inside the while (SDL_PollEvent(&event)) loop and that the condition checks && gameOver.
Why isn't my game-over or health bar working correctly?
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.cpp and your game is running with collisions, a health bar, and a game-over screen.
ⓧ I'd like to double check the full code
Here is the complete src/main.cpp at the end of this step:
#include <SDL3/SDL.h>
#include <vector>
#include <cmath>
#include <cstdlib>
#include <ctime>
// --- Constants ---
const int SCREEN_WIDTH = 800;
const int SCREEN_HEIGHT = 600;
const float PLAYER_SPEED = 300.0f;
const float BULLET_SPEED = 600.0f;
const float ENEMY_SPEED = 120.0f;
const float SPAWN_INTERVAL = 1.5f;
const int MAX_HEALTH = 5;
// --- Structs ---
struct Player {
float x, y;
float size;
float speed;
int health;
};
struct Bullet {
float x, y;
float vx, vy;
float radius;
bool alive;
};
struct Enemy {
float x, y;
float radius;
float speed;
bool alive;
};
// --- Helper Functions ---
float length(float x, float y) {
return std::sqrt(x * x + y * y);
}
float distance(float x1, float y1, float x2, float y2) {
return length(x2 - x1, y2 - y1);
}
bool circleCollision(float x1, float y1, float r1, float x2, float y2, float r2) {
return distance(x1, y1, x2, y2) < (r1 + r2);
}
// --- Main ---
int main(int argc, char* args[]) {
if (!SDL_Init(SDL_INIT_VIDEO)) {
SDL_Log("Failed to initialize SDL: %s", SDL_GetError());
return 1;
}
SDL_Window* window = nullptr;
SDL_Renderer* renderer = nullptr;
if (!SDL_CreateWindowAndRenderer("Twin-Stick Shooter", SCREEN_WIDTH, SCREEN_HEIGHT, 0, &window, &renderer)) {
SDL_Log("Failed to create window/renderer: %s", SDL_GetError());
SDL_Quit();
return 1;
}
std::srand(static_cast<unsigned>(std::time(nullptr)));
Player player = {SCREEN_WIDTH / 2.0f, SCREEN_HEIGHT / 2.0f, 20.0f, PLAYER_SPEED, MAX_HEALTH};
std::vector<Bullet> bullets;
std::vector<Enemy> enemies;
int score = 0;
bool gameOver = false;
float spawnTimer = 0.0f;
Uint64 lastTime = SDL_GetPerformanceCounter();
float frequency = static_cast<float>(SDL_GetPerformanceFrequency());
bool running = true;
while (running) {
Uint64 currentTime = SDL_GetPerformanceCounter();
float deltaTime = (currentTime - lastTime) / frequency;
lastTime = currentTime;
if (deltaTime > 0.05f) deltaTime = 0.05f;
SDL_Event event;
bool shootThisFrame = false;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_EVENT_QUIT) {
running = false;
}
if (event.type == SDL_EVENT_MOUSE_BUTTON_DOWN && event.button.button == SDL_BUTTON_LEFT) {
shootThisFrame = true;
}
if (event.type == SDL_EVENT_KEY_DOWN && event.key.scancode == SDL_SCANCODE_R && gameOver) {
player = {SCREEN_WIDTH / 2.0f, SCREEN_HEIGHT / 2.0f, 20.0f, PLAYER_SPEED, MAX_HEALTH};
bullets.clear();
enemies.clear();
score = 0;
gameOver = false;
spawnTimer = 0.0f;
}
}
if (gameOver) {
SDL_SetRenderDrawColor(renderer, 40, 0, 0, 255);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, 255, 0, 0, 255);
SDL_FRect goRect = {SCREEN_WIDTH / 2.0f - 100, SCREEN_HEIGHT / 2.0f - 20, 200, 40};
SDL_RenderFillRect(renderer, &goRect);
SDL_RenderPresent(renderer);
continue;
}
// --- Input ---
const bool* keyState = SDL_GetKeyboardState(nullptr);
float moveX = 0.0f, moveY = 0.0f;
if (keyState[SDL_SCANCODE_W]) moveY -= 1.0f;
if (keyState[SDL_SCANCODE_S]) moveY += 1.0f;
if (keyState[SDL_SCANCODE_A]) moveX -= 1.0f;
if (keyState[SDL_SCANCODE_D]) moveX += 1.0f;
float moveLen = length(moveX, moveY);
if (moveLen > 0.0f) {
moveX /= moveLen;
moveY /= moveLen;
}
// --- Update Player ---
player.x += moveX * player.speed * deltaTime;
player.y += moveY * player.speed * deltaTime;
if (player.x < player.size / 2) player.x = player.size / 2;
if (player.x > SCREEN_WIDTH - player.size / 2) player.x = SCREEN_WIDTH - player.size / 2;
if (player.y < player.size / 2) player.y = player.size / 2;
if (player.y > SCREEN_HEIGHT - player.size / 2) player.y = SCREEN_HEIGHT - player.size / 2;
// --- Mouse Aim ---
float mouseX, mouseY;
SDL_GetMouseState(&mouseX, &mouseY);
float aimDirX = mouseX - player.x;
float aimDirY = mouseY - player.y;
float aimLen = length(aimDirX, aimDirY);
if (aimLen > 0.0f) {
aimDirX /= aimLen;
aimDirY /= aimLen;
}
// --- Shoot ---
if (shootThisFrame) {
Bullet b;
b.x = player.x;
b.y = player.y;
b.vx = aimDirX * BULLET_SPEED;
b.vy = aimDirY * BULLET_SPEED;
b.radius = 5.0f;
b.alive = true;
bullets.push_back(b);
}
// --- Update Bullets ---
for (auto& b : bullets) {
if (!b.alive) continue;
b.x += b.vx * deltaTime;
b.y += b.vy * deltaTime;
if (b.x < -50 || b.x > SCREEN_WIDTH + 50 || b.y < -50 || b.y > SCREEN_HEIGHT + 50) {
b.alive = false;
}
}
// --- Spawn Enemies ---
spawnTimer += deltaTime;
if (spawnTimer >= SPAWN_INTERVAL) {
spawnTimer = 0.0f;
Enemy e;
e.radius = 15.0f;
e.speed = ENEMY_SPEED;
e.alive = true;
int edge = std::rand() % 4;
switch (edge) {
case 0: e.x = static_cast<float>(std::rand() % SCREEN_WIDTH); e.y = -e.radius; break;
case 1: e.x = static_cast<float>(std::rand() % SCREEN_WIDTH); e.y = SCREEN_HEIGHT + e.radius; break;
case 2: e.x = -e.radius; e.y = static_cast<float>(std::rand() % SCREEN_HEIGHT); break;
case 3: e.x = SCREEN_WIDTH + e.radius; e.y = static_cast<float>(std::rand() % SCREEN_HEIGHT); break;
}
enemies.push_back(e);
}
// --- Update Enemies ---
for (auto& e : enemies) {
if (!e.alive) continue;
float dirX = player.x - e.x;
float dirY = player.y - e.y;
float dirLen = length(dirX, dirY);
if (dirLen > 0.0f) {
dirX /= dirLen;
dirY /= dirLen;
}
e.x += dirX * e.speed * deltaTime;
e.y += dirY * e.speed * deltaTime;
}
// --- Collision: Bullets vs Enemies ---
for (auto& b : bullets) {
if (!b.alive) continue;
for (auto& e : enemies) {
if (!e.alive) continue;
if (circleCollision(b.x, b.y, b.radius, e.x, e.y, e.radius)) {
b.alive = false;
e.alive = false;
score++;
break;
}
}
}
// --- Collision: Enemies vs Player ---
for (auto& e : enemies) {
if (!e.alive) continue;
if (circleCollision(player.x, player.y, player.size / 2, e.x, e.y, e.radius)) {
e.alive = false;
player.health--;
if (player.health <= 0) {
gameOver = true;
}
}
}
// --- Remove dead entities ---
bullets.erase(
std::remove_if(bullets.begin(), bullets.end(), [](const Bullet& b) { return !b.alive; }),
bullets.end());
enemies.erase(
std::remove_if(enemies.begin(), enemies.end(), [](const Enemy& e) { return !e.alive; }),
enemies.end());
// --- Render ---
SDL_SetRenderDrawColor(renderer, 20, 20, 30, 255);
SDL_RenderClear(renderer);
SDL_SetRenderDrawColor(renderer, 0, 200, 100, 255);
SDL_FRect playerRect = {player.x - player.size / 2, player.y - player.size / 2, player.size, player.size};
SDL_RenderFillRect(renderer, &playerRect);
SDL_SetRenderDrawColor(renderer, 100, 100, 100, 255);
SDL_RenderLine(renderer, player.x, player.y, player.x + aimDirX * 40, player.y + aimDirY * 40);
SDL_SetRenderDrawColor(renderer, 255, 255, 0, 255);
for (const auto& b : bullets) {
SDL_FRect bulletRect = {b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2};
SDL_RenderFillRect(renderer, &bulletRect);
}
SDL_SetRenderDrawColor(renderer, 255, 50, 50, 255);
for (const auto& e : enemies) {
SDL_FRect enemyRect = {e.x - e.radius, e.y - e.radius, e.radius * 2, e.radius * 2};
SDL_RenderFillRect(renderer, &enemyRect);
}
SDL_SetRenderDrawColor(renderer, 80, 80, 80, 255);
SDL_FRect healthBg = {10, 10, 100, 15};
SDL_RenderFillRect(renderer, &healthBg);
SDL_SetRenderDrawColor(renderer, 0, 255, 0, 255);
float healthWidth = 100.0f * (static_cast<float>(player.health) / MAX_HEALTH);
SDL_FRect healthFg = {10, 10, healthWidth, 15};
SDL_RenderFillRect(renderer, &healthFg);
SDL_RenderPresent(renderer);
}
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return 0;
}
Your twin-stick shooter is now fully playable. You have movement, aiming, shooting, enemies, collisions, scoring, and a game-over state. This is a complete game loop with real stakes.
Secret mission
Add a Dash Ability
Your shooter is playable, but enemies are relentless. What if you could burst through danger? Add a dash/dodge ability with a cooldown timer, temporary invincibility, and visual feedback to give players a fighting chance.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. SDL3 is free and open-source, and your compiled executable is just a file on your machine.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Project folder on your Desktop (contains CMakeLists.txt, src/main.cpp, and the build/ directory with your compiled executable).
- SDL3 (installed via Homebrew).
- CMake (installed via Homebrew).
Keep everything running
No action needed. Choose this if you're still actively building or want to experiment further with your game.
- Your project folder stays on your Desktop. You can rebuild anytime by running cmake --build build from inside the project folder.
- SDL3 and CMake remain installed via Homebrew. They take minimal disk space and don't run any background processes.
Pause - I'll come back to this later
There are no running processes to stop. Your game only runs when you explicitly launch the executable.
- You can delete the build/ directory to free disk space. Rebuild later with cmake -B build and cmake --build build.
- Keep SDL3 and CMake installed so you can rebuild without reinstalling anything.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Delete your project folder by running:
cd ~/Desktop
rm -rf TwinStickShooter
- Uninstall SDL3 by running:
brew uninstall sdl3
- Uninstall CMake by running:
brew uninstall cmake
Other tools depend on CMake?
If you use CMake for other projects, skip that last command. Homebrew will warn you if other installed formulas depend on it.
That's a Wrap!
That's a Wrap!
Nice work! You just built a real game from scratch. No engine. No framework magic. Just you, C++, and SDL3.
You've learned how to:
- Built a native twin-stick shooter as a compiled macOS executable using C++ and SDL3, with no game engine involved.
- Implemented a game loop with delta time for frame-rate-independent movement, keyboard polling for WASD controls, and mouse state for aiming.
- Created a projectile system using 2D vector math, an enemy spawner with chase AI, and circle-circle collision detection written entirely from scratch.
- Added a dash/dodge mechanic with a cooldown timer and invincibility frames for a polished feel.
Ready to quiz yourself?