Capstone: a 2D game#
A complete small game: a paddle, a ball, a score, keyboard control and a loop that runs at a steady frame rate. It is built on the native SDL2 binding, which gives a window, an accelerated renderer, an event queue and timing in one module.
A game is a loop: read input, update the world, draw the world, wait for the next frame, repeat. Everything else is detail.
The world is plain data#
use SDL2 qw(:all);
my ($W, $H) = (480, 360);
my %game = (
ball => { x => 240, y => 180, dx => 3, dy => 3, r => 8 },
paddle => { x => 200, w => 80, h => 12, y => $H - 30 },
score => 0,
over => 0,
);
Deliberately a plain hash. The update step is ordinary arithmetic on ordinary data and the draw step reads the same data; nothing about either is graphics-specific until the last moment.
Update#
Advance the ball, bounce it off three walls and the paddle, end the game if it passes below.
sub update {
return if $game{over};
my $b = $game{ball};
$b->{x} += $b->{dx};
$b->{y} += $b->{dy};
# Clamp AND reverse. Reversing alone lets a fast ball that
# overshoots the wall reverse again next frame and stick in it.
if ($b->{x} - $b->{r} < 0) { $b->{x} = $b->{r}; $b->{dx} = -$b->{dx} }
if ($b->{x} + $b->{r} > $W) { $b->{x} = $W - $b->{r}; $b->{dx} = -$b->{dx} }
if ($b->{y} - $b->{r} < 0) { $b->{y} = $b->{r}; $b->{dy} = -$b->{dy} }
my $p = $game{paddle};
if ($b->{y} + $b->{r} >= $p->{y}
&& $b->{x} >= $p->{x} && $b->{x} <= $p->{x} + $p->{w}
&& $b->{dy} > 0) { # only when moving downward
$b->{y} = $p->{y} - $b->{r};
$b->{dy} = -$b->{dy};
$game{score}++;
}
$game{over} = 1 if $b->{y} - $b->{r} > $H;
}
Two conditions in there are load-bearing and both guard against the same class of bug. The $b->{dy} > 0 test stops the ball scoring repeatedly while it is still overlapping the paddle on its way out. The clamps stop a ball that overshoots a wall from reversing again on the next frame and vibrating inside it. Symptom: the ball sticks to a surface and shudders. Cause: reversing velocity without correcting position.
Draw#
The frame is a function of the world state, recomputed from scratch each time.
sub draw {
my ($ren) = @_;
$ren->set_draw_color(20, 20, 30);
$ren->clear;
my $p = $game{paddle};
$ren->set_draw_color(230, 230, 230);
$ren->fill_rect([ $p->{x}, $p->{y}, $p->{w}, $p->{h} ]);
my $b = $game{ball};
$ren->set_draw_color(250, 210, 60);
$ren->fill_rect([ $b->{x} - $b->{r}, $b->{y} - $b->{r},
$b->{r} * 2, $b->{r} * 2 ]);
$ren->present;
}
Clearing and redrawing everything is the immediate-mode style: simple to reason about, and fast enough that a game this size never needs anything cleverer. The ball is a square here because SDL’s renderer has no circle primitive; a real one would use a texture, or the midpoint circle from the algorithms chapter.
Input: events tell you about changes, not state#
This is the distinction that decides whether movement feels right.
The event queue reports transitions: this key went down, that one came up. Polling it and moving the paddle per SDL_KEYDOWN gives movement governed by the operating system’s key-repeat delay, which stutters: one step, a pause, then a stream.
To know whether a key is held right now, ask the keyboard state instead.
sub input {
my $p = $game{paddle};
SDL_PumpEvents();
$p->{x} -= 6 if SDL2::key_pressed(SDL_SCANCODE_LEFT);
$p->{x} += 6 if SDL2::key_pressed(SDL_SCANCODE_RIGHT);
$p->{x} = 0 if $p->{x} < 0;
$p->{x} = $W - $p->{w} if $p->{x} > $W - $p->{w};
}
key_pressed reads SDL’s live key array, so it is a state query rather than an event, and it moves the paddle a fixed amount every frame for as long as the key is down. SDL_PumpEvents is what refreshes that array, and omitting it leaves the state frozen.
Note SDL_SCANCODE_LEFT rather than SDLK_LEFT. A scancode is a physical key position and a keysym is what that key means under the current layout. Arrow keys are the same either way, but WASD is not: on an AZERTY keyboard the keysyms are ZQSD while the scancodes stay where the fingers are. Movement wants scancodes; text and shortcuts want keysyms.
The loop#
SDL_Init(SDL_INIT_VIDEO) == 0 or die "SDL_Init: ", SDL_GetError(), "\n";
my $win = SDL2::Window->new(title => 'pperl bounce', w => $W, h => $H);
my $ren = $win->renderer(vsync => 1) or die "renderer: ", SDL_GetError(), "\n";
my $running = 1;
while ($running) {
while (my $ev = SDL_PollEvent()) {
$running = 0 if $ev->{type} == SDL_QUIT;
}
input();
update();
draw($ren);
}
vsync => 1 ties present to the display refresh, which paces the loop without a sleep and avoids tearing. Without it, add SDL_Delay(16) or the loop spins as fast as the CPU allows.
Draining the event queue completely each frame matters: a while rather than an if. A single-event-per-frame loop falls progressively further behind whenever events arrive faster than frames are drawn, and the symptom is input that lags seconds behind the user.
Running the game#
Assemble the pieces into one file and run it with pperl. Arrow keys move the paddle, each hit with the paddle scores, and missing ends the game.
The whole program has been checked over a run of frames, both in a window and headless (SDL_VIDEODRIVER=dummy): the ball advances by its velocity, bounces off the left wall and is pushed back inside to x=8, scores and reverses on paddle contact, and ends the game when it passes the bottom. The drawn frame really does contain the ball’s colour (250, 210, 60) at the ball’s centre, the paddle’s colour on the paddle, and the background colour everywhere else. With vsync => 1 the loop runs at the display refresh: at 60 Hz, sixty frames take one second.
Under the hood: what the binding is doing#
use SDL2 is a native binding built on Peta::FFI, the same pattern as the Cairo showcase: the library is opened once, each handle is an integer inside a blessed reference, and DESTROY frees it.
The raw layer is still reachable, and worth seeing once because it shows what the binding removes. Polling an event without it means declaring SDL_Event, a C union, and the structs inside it, then passing one by pointer for SDL_PollEvent to fill in:
use Peta::FFI;
my $sdl = Peta::FFI::dlopen("libSDL2-2.0.so.0");
Peta::FFI::record("Keysym", scancode => "i32", sym => "i32", mod => "u16", unused => "u32");
Peta::FFI::record("KeyEvent", type => "u32", timestamp => "u32", windowID => "u32",
state => "u8", repeat => "u8", pad => "u8[2]", keysym => "{Keysym}");
Peta::FFI::union("Event", type => "u32", key => "{KeyEvent}", padding => "u8[56]");
my $ev = Event->new; # one SDL_Event
while (Peta::FFI::call($sdl, "SDL_PollEvent", "(*{Event})i", $ev)) {
next unless $ev->type == 0x300; # SDL_KEYDOWN
my $sym = $ev->key->keysym->sym;
}
union works as record does, except that every member starts at offset 0, so the union is as large as its largest member. A union is always passed by pointer, as *{Event}; a signature that passes one by value croaks, because a C call cannot pass it that way reliably.
That works, and every member in it was transcribed by hand from SDL’s headers: the types and their order, the 0x300, the 56 bytes of padding that make the union as large as SDL’s. The signature "(*{Event})i" was written by hand too, from the C prototype. Peta::FFI computes the offsets, but a declaration that disagrees with the header is undefined behaviour rather than an error. The binding pays that cost once, in one place, checked once, which is the argument for a native binding over scattered raw calls.
Where the guide ends#
You have built two programs that run: a drawing application and a game, both on bindings pperl ships, with the raw FFI layer shown where it clarifies rather than where it is needed. The final chapter draws the boundary of this guide; this game’s sequel is the 3D game at the end of the 3D guide.