SDL 2.0 Event Loop Mastery: Polling, Timers, and Low CPU
Learn how to build a responsive, low‑CPU SDL 2.0 event loop that blends polling and waiting, uses timers, and avoids threading pitfalls. Includes a concrete example and trade‑off analysis.
24 Oct 2025, 22:03 UTC

Concrete Problem: How to Keep a Game Loop Responsive Without Burning CPU
When you build a 2‑D or 3‑D game with SDL 2.0, the simplest way to react to keyboard, mouse, or joystick input is to pull events from SDL’s queue each frame. If you do this naively, you either spin the CPU at 100 % or you introduce noticeable lag. The challenge is to pick the right event‑driven strategy, handle timers, and avoid queue overflows—all while keeping the code readable.
Thesis: Use a single‑threaded, event‑driven loop that mixes SDL_PollEvent with SDL_WaitEvent and user events for timers.
SDL’s event system centralises all input and timer events in one queue. By polling when you need immediate responsiveness and waiting when idle, you get low latency, low CPU usage, and a clean architecture that scales to complex games.
Why a Centralized Event Queue Matters
- All input sources (keyboard, mouse, gamepad) and timers share a single queue; no per‑device polling is required.
- The queue is thread‑safe for pushing but must be processed from one thread to avoid race conditions.
- Custom events (
SDL_USEREVENT) let you embed game logic (e.g., animation frames, AI steps) directly into the loop.
Polling vs. Waiting: Latency and CPU Trade‑offs
Typical loop skeleton:
while (running) {
SDL_Event e;
while (SDL_PollEvent(&e)) {
handleEvent(e);
}
updateGame();
render();
}
SDL_PollEvent returns immediately. If you run this at 60 Hz, the loop may spin many times per frame, consuming CPU. Adding a short SDL_Delay(1) or a frame limiter solves this, but at the cost of a slightly higher latency when an event arrives.
Alternatively, SDL_WaitEvent blocks until an event is queued. This guarantees 0 % CPU usage when idle, but you lose the ability to update the game state on a fixed timestep unless you also run a timer event. A hybrid approach—poll a few times, then wait for the next event—offers the best of both worlds.
Timers Inside the Event Loop
Instead of busy‑waiting or sleeping, use SDL’s timer API to push a SDL_USEREVENT at regular intervals. Example: a 60 Hz tick.
Uint32 tickCallback(Uint32 interval, void *param) {
SDL_Event e;
e.type = SDL_USEREVENT;
e.user.code = 1; // Tick code
SDL_PushEvent(&e);
return interval; // reschedule
}
Uint32 tickTimer = SDL_AddTimer(16, tickCallback, NULL); // ~60Hz
Now your main loop can be fully event‑driven:
while (running) {
SDL_Event e;
if (SDL_WaitEvent(&e)) {
if (e.type == SDL_USEREVENT && e.user.code == 1) {
updateGame();
}
handleEvent(e);
}
}
Because the timer pushes an event, the loop remains responsive to input while the game logic runs on a fixed schedule.
Threading Pitfalls
SDL’s event queue is safe for pushing from any thread, but only one thread should call SDL_PollEvent or SDL_WaitEvent. Mixing event processing across threads leads to race conditions and undefined behavior. If you need background work, queue work‑items into a separate thread‑safe queue and signal the main thread with a custom event.
Concrete Worked Example
Below is a minimal C++ program that demonstrates:
- Creating a window
- Setting up a 60 Hz timer that pushes a
SDL_USEREVENT - Using
SDL_WaitEventfor low CPU usage - Handling
SDL_QUITand keyboard events
#include <SDL.h>
#include <iostream>
Uint32 tickCallback(Uint32 interval, void *param) {
SDL_Event e;
e.type = SDL_USEREVENT;
e.user.code = 1; // Tick
SDL_PushEvent(&e);
return interval;
}
int main() {
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
std::cerr << "SDL_Init error: " << SDL_GetError() << std::endl;
return 1;
}
SDL_Window *win = SDL_CreateWindow("SDL Event Loop Demo",
SDL_WINDOWPOS_CENTERED,
SDL_WINDOWPOS_CENTERED,
640, 480,
SDL_WINDOW_SHOWN);
if (!win) {
std::cerr << "CreateWindow error: " << SDL_GetError() << std::endl;
SDL_Quit();
return 1;
}
Uint32 timer = SDL_AddTimer(16, tickCallback, nullptr); // ~60Hz
bool running = true;
while (running) {
SDL_Event e;
if (!SDL_WaitEvent(&e)) {
std::cerr << "WaitEvent error: " << SDL_GetError() << std::endl;
break;
}
switch (e.type) {
case SDL_QUIT:
running = false;
break;
case SDL_USEREVENT:
if (e.user.code == 1) {
// Fixed‑rate game update
std::cout << "Tick update" << std::endl;
}
break;
case SDL_KEYDOWN:
std::cout << "Key pressed: " << SDL_GetKeyName(e.key.keysym.sym) << std::endl;
break;
default:
break;
}
}
SDL_RemoveTimer(timer);
SDL_DestroyWindow(win);
SDL_Quit();
return 0;
}
Compile (Linux example): g++ -std=c++17 -o sdl_demo sdl_demo.cpp `sdl2-config --cflags --libs`. Running the binary shows console output for key presses and tick updates every ~16 ms, while the CPU usage stays near 0 % when idle.
Trade‑offs and Limitations
- Single‑threaded event handling simplifies design but can become a bottleneck if game logic is heavy. Offload intensive tasks to worker threads and signal the main thread via
SDL_USEREVENT. - Large volumes of
SDL_USEREVENTcan saturate the queue. Consolidate timers or useSDL_TimerCreatefor lightweight, non‑blocking callbacks. - Event queue overflow is rare but possible if events are generated faster than processed. Keep the event loop tight and avoid blocking operations inside
handleEvent.
Actionable Take‑aways
- Start with
SDL_WaitEventfor low CPU usage; add a timer event if you need a fixed update rate. - Keep all event processing in one thread; use custom events to communicate with background workers.
- Monitor the event queue size with
SDL_PeepEventsto detect overflows early. - When switching from
SDL_PollEventtoSDL_WaitEvent, test latency for key‑presses to ensure they meet your game’s responsiveness requirements.
By following this pattern, you’ll build a responsive, CPU‑friendly game loop that scales with complexity without sacrificing performance.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.