All Projects

Pilly

A 2D game engine from scratch—SDL3, raw pointers, and all the footguns that come with them.

What Is This?

Pilly is a bare-bones 2D game engine built on SDL3. Sprite rendering, gravity, collision detection, mouse input—nothing fancy, but all written from scratch. The goal was to understand what actually happens between "draw a sprite" and pixels appearing on screen.

It's not production-ready. There are known footguns. But that was kind of the point.

The Rendering Stack

SDL3 abstracts away the graphics API—Metal on macOS, Direct3D on Windows, Vulkan on Linux. You call SDL_CreateWindowAndRenderer() and get both handles back. From there, it's just SDL_RenderTexture() calls into a command buffer, then SDL_RenderPresent() to flip.

I built this on SDL3 specifically, which meant dealing with breaking changes from SDL2: SDL_RenderTexture() instead of SDL_RenderCopy(), new event constant names, type-safe pixel format enums. The upside is floating-point rectangles (SDL_FRect) for sub-pixel positioning if I ever need it.

Loading Images

Textures load through STB Image—a single-header library that handles JPEG, PNG, BMP, and TGA. The pipeline goes:

  1. stbi_load() gives you raw pixels and a channel count
  2. Figure out the format: 4 channels → RGBA32, 3 channels → RGB24
  3. Calculate pitch (bytes per row): width × channels
  4. Wrap pixels in an SDL_Surface (CPU-side)
  5. Upload to GPU via SDL_CreateTextureFromSurface()
  6. Free the CPU-side stuff immediately—the texture lives in VRAM now

Path resolution uses a fallback array to handle different working directories. First successful load wins.

Memory: Raw Pointers Everywhere

Each sprite owns its SDL_Texture* and cleans it up in the destructor. A global std::list<sprite*> holds non-owning pointers for collision checks and rendering.

Sprites are stack-allocated in main(), so destruction order is deterministic. But the constructor has a side effect—it pushes this into the sprite list and stores a pointer back to it. That's a dangling pointer waiting to happen if you destroy a sprite mid-loop.

I know. It's not great. But I learned exactly why smart pointers and proper lifetime management exist.

Physics: Euler Integration

Gravity uses explicit Euler—the simplest possible integration method. Each frame, velocity increases by 1 pixel/frame² if you're above ground. Position updates by accumulated velocity. Hit the floor, velocity resets to zero.

No bounce. No energy transfer. No delta time.

That last part is the real problem: physics is tied to frame rate. If you're running at 60 FPS, gravity is 1 px/frame². Drop to 30 FPS, and everything falls in slow motion. A proper implementation would multiply by delta time. This one doesn't.

Collision: AABB

Collision detection uses axis-aligned bounding boxes with the Separating Axis Theorem (simplified for rectangles). Check four conditions:

  • Am I entirely left of the target?
  • Am I entirely right of it?
  • Am I entirely above it?
  • Am I entirely below it?

If any of those are true, there's a separating axis and no collision. Otherwise, you're overlapping.

The system tests the proposed position before moving—predictive detection. This prevents tunneling through objects but creates "sticky" collision where sprites can't slide along surfaces.

Performance is O(n²). Every sprite checks against every other sprite. No quadtree, no spatial hashing. The std::list makes it worse—pointer chasing kills cache locality.

Input & Events

SDL's event queue gets drained every frame. Every sprite receives every event—pure broadcast, no filtering. Simple but wasteful for large sprite counts.

Mouse dragging tracks the offset between click position and sprite origin, so the sprite doesn't snap to cursor. While dragged, the sprite follows the mouse minus that offset. One quirk: drag updates run on every event poll, even when the mouse hasn't moved.

Frame Timing

Timing is just SDL_Delay(16)—a fixed 16ms sleep targeting 60 FPS. This is wrong in multiple ways:

  • Doesn't account for actual frame time (event processing, collision checks, rendering)
  • OS scheduling adds jitter
  • Frame rate drops below 60 under any load

The right approach uses SDL_GetPerformanceCounter() to measure elapsed time and apply it to physics. This engine doesn't do that.

Threading

Single-threaded. All logic on main thread. SDL's event queue is thread-safe, but I'm not using that. No worker threads for physics or asset loading.

The GPU runs in parallel naturally—SDL3's renderer submits commands asynchronously, and SDL_RenderPresent() may block on vsync while the GPU catches up.

Build System

CMake handles cross-platform builds. macOS links against Homebrew's ARM64 SDL3. Windows uses a hard-coded path to the MinGW distribution with a post-build step to copy SDL3.dll.

The hard-coded paths won't work on anyone else's machine. Should use find_package(SDL3). Didn't.

What I'd Do Differently

  • Delta time — Decouple physics from frame rate
  • Spatial partitioning — Quadtree or grid hash for collision
  • Smart pointers — Or at least proper lifetime tracking
  • Sprite batching — One draw call per texture, not per sprite
  • Texture atlas — Stop loading the same image multiple times
  • Async loading — Don't block main thread on image decode
  • Error handling — Failed loads shouldn't leave sprites in broken states

This project was about learning the hard way. Mission accomplished.