Writing a 2D Platform Game in Nim with SDL2
2016-06-14 · HookRace · Nim · DDNet · ProgrammingIn this article we’re going to write a simple 2D platform game. You can also consider this as a tutorial for game development with SDL2 in Nim.
We will read in user input, display graphics and a tile map, and simulate simple 2D physics with collision detection and handling. Afterwards we will implement simple camera movement and game logic. To display some information we will render texts and develop a caching mechanism for said text rendering.
The final result will be a binary file that requires only SDL2 and can be easily distributed, perfect for games. If you’re on Linux we will also present a simple way to cross-compile Nim programs for Windows.
For the sake of simplicity we’re going to use the familiar graphics from DDNet and Teeworlds, with the end result of this tutorial looking like this:
We’re going to follow along with the development throughout this article with illustrative images and videos, but the best way to learn is if you follow along yourself by implementing the steps from this article. The code is purposefully kept simple and easy to extend so that you can play around with it and try out all kinds of changes to get an intuitive understanding. At the end of every section there is a link to its full source code.
The iterations of the code of this article and the final result are available in a repository on GitHub. The resulting binaries can be downloaded here: Win64, Win32, Linux x86_64, Linux x86
Preliminaries
For this post we require:
- The Nim programming language and its package manager Nimble
- SDL 2, SDL_image 2, SDL_ttf 2 (all for developers)
- Nim SDL2 wrapper and strfmt, which can be installed using Nimble
On a unixoid system like Linux or Mac OS X the installation looks something like this:
# Debian / Ubuntu
$ sudo apt-get install git libsdl2-dev libsdl2-image-dev libsdl2-ttf-dev
# Arch Linux
$ pacman -S git sdl2 sdl2_image sdl2_ttf
# Homebrew on OS X
$ brew install git sdl2 sdl2_image sdl2_ttf
# FreeBSD
$ pkg install git sdl2 sdl2_image sdl2_ttf
$ wget http://nim-lang.org/download/nim-0.14.2.tar.xz
$ tar xvf nim-0.14.2.tar.xz
$ cd nim-0.14.2
$ make -j4
$ echo 'export PATH=$HOME/nim-0.14.2/bin:$PATH' >> ~/.profile
$ source ~/.profile
$ git clone https://github.com/nim-lang/nimble.git
$ cd nimble
$ nim -d:release c -r src/nimble install
$ echo 'export PATH=$HOME/.nimble/bin:$PATH' >> ~/.profile
$ source ~/.profile
$ nimble install sdl2 strfmt
Note that you also need a C compiler on your system, preferably GCC or Clang.
Instead of compiling Nim and Nimble from source code you could also use your package manager to install Nim and Nimble if they’re available in a recent version.
For setting up SDL2 on other platforms more extensive guides exist, as do for Nim and nimble.
1. First Running Program
If you have seen SDL2 programs written in C or C++ before, you will notice that what we’re doing in Nim is very similar. Actually Nim’s SDL2 wrapper is just a thin layer wrapping the original SDL2 interface from C. This has the advantage that what you learn from any SDL2 tutorial is applicable, but the disadvantage is that you end up with a bit more boilerplate than with a more high-level Nim library.
We start with exactly this boilerplate to initialize our window:
We introduced an sdlFailIf
template that checks a condition and if the
condition is true, raises an SDLException
with additional error information
from SDL. In the main
proc we initialize SDL2, and create a regular window
and an accelerated 2D renderer. Error handling is done with the sdlFailIf
proc that we introduced.
For now the game loop just clears the window and draws it every frame. If you have VSync enabled and your screen is set to 60 Hz the loop will be executed 60 times per second.
We can compile and run in the same step by executing nim -r c platformer
,
assuming you called the file platformer.nim
. To compile with optimizations
use nim -d:release -r c platformer
. The result is a simple one-colored
window:
We can exit our small program by pressing Ctrl-C in the terminal window. Unfortunately we can’t exit it in the game window itself yet, so let’s fix that.
2. User Input
First let’s add an Input
type to store all the inputs we want to support, and
store an array of Inputs in our game state object:
By choosing a ref
type for the Game
state type we have an easy way to
prevent accidentally creating copies of it. By default only the garbage
collected pointer to our Game object is passed around. The inputs
field is an
array mapping from Input
to bool
, signifying which input is currently
pressed (true
) or not (false
). Creating a new game state object is trivial,
we just create a new heap object for now and assign the SDL2 renderer
that we
will need later:
We don’t need to initialize the inputs
field in any way as everything is
initialized to binary null by default, which is exactly what we want: Every
input is set to off in the start. If we didn’t initialize the renderer
field
it would be a null pointer and we would get into trouble if we accidentally
dereference it.
The next thing we need is a procedure that maps keyboard scan codes to our recognized inputs:
Note that toInput
returns Input.none
for all undefined cases. We will use
this behaviour to ignore unused keyboard inputs without the need for a branch
in our code. You could easily recognize multiple scan codes to map to a single
input.
We modify the game loop to react to keyboard inputs by calling our new
handleInput
proc. We also split out the rendering itself to keep the
separation of concerns clear in the main
proc:
Now we can either press q or use the close button on the window to close
the game. Other kinds of user input are just stored in the inputs
array for
now and we will be able to use it later.
Note that the inputs array is intentionally a simple array, so that access to it is as performant as can be. If we used a hash table or some other data structure such a guarantee would not be that easy to make. Simplicity can often be beneficial and make it easier to understand what is going on in the system you’re developing.
3. Displaying Graphics
If we want to do something of interest with those user inputs we need to start displaying something other than a blue sky.
We extend our game state object to also store the player texture as well as the current position and velocity, for which we use the basic2d module:
We’re going to use Teeworlds’ default tee graphic for our player:
You can save this file as player.png to follow along. If you feel funny you can also select one of hundreds of skins from the DDNet Skin Database, for example:
Don’t forget to call your player graphic player.png
if you want to use an
alternative one.
First we have to load the player graphic, whichever one you decided to use, in
our definition of newGame
and initialize the Game
and Player
data
structures:
restartPlayer
is used to reset the player to its start position. The
loadTexture
procedure loads the PNG image into memory as an SDL2 texture that
we can store in the Game
object.
We also shouldn’t forget to initialize the SDL2 image module in our main
proc, similarly to the SDL2 initialization:
We only need support for PNG files, otherwise we could also add JPEG files with
const imgFlags: cint = IMG_INIT_PNG or IMG_INIT_JPG
.
Next our task is to put this together nicely. Of course the intention of the flexible player images is that parts of the body can move independently, but for the sake of simplicity we will put them into fixed positions. A simple addition would be to make the feet move in a rotating motion depending on the horizontal position of the player. Another addition would be to make the eyes follow the mouse cursor.
The exact numbers are not so important, they are just how the player is meant
to be put together. With renderTee
we define which body part is drawn at
which position and in which order. Finally each of these body parts is drawn
with the SDL2 renderer using copyEx
.
Now drawing the tee in our game loop is simply a call to renderTee
away:
Finally we have some visual progress again, look at the player floating in the sky:
4. Tile Map
Now that we have a working rendering system for the player, we need a map to play in. This requires us to store a texture as well as a list of tiles:
Each tile is defined to be a uint8
, which means a value between 0 and 255
inclusively. Conveniently the tileset graphic for from Teeworlds have 16 × 16 =
256 tiles. We will use the grass tileset:
Download and save this image as grass.png.
To initialize the Map
data structure we shall write a little parser that
parses maps of this format:
0 0 0 0 78 0 0 0 0 0 0 0 0 0 0
4 5 0 0 78 0 0 0 0 0 0 0 0 0 0
20 21 0 0 78 0 0 0 0 0 0 0 0 0 0
20 21 0 0 78 0 0 0 0 0 0 0 0 4 5
20 21 0 0 78 0 0 0 0 0 0 0 0 20 21
20 21 0 0 78 0 0 0 0 0 0 0 0 20 21
20 21 0 0 78 0 0 4 5 0 0 0 0 20 21
20 21 0 0 78 0 0 20 21 0 0 0 0 20 21
20 38 0 0 78 0 0 22 38 0 0 0 0 22 38
20 49 16 16 16 16 16 48 49 16 16 16 16 48 49
36 52 52 52 52 52 52 52 52 52 52 52 52 52 52
Our goal in this section is for this map with the grass tileset to result in this rendering:
Each number denotes the tile that is chosen from the grass tileset. We will use
this default.map for the rest of this article. Our
parser is implemented in newMap
and looks like this:
We split the file by line as well as by word. Each number is parsed to an
unsigned integer and checked whether it is in the valid range of 0..255
. The
final width and height are calculated from the line length and number of lines.
Errors in the map data cause an exception to be thrown, quitting our game.
We have to extend newGame
to initialize the map now:
At this point we have the texture and the tiles of the map. What’s missing is actually rendering all of this:
This is similar to renderTee
but uses fixed size parts of the texture. The
texture is cut into tiles of size 64 × 64 pixels with 16 tiles per line. We
iterate over each tile in the map and render the tile tileNr
from our map
texture with. Tile 0 is the air tile and is always empty so we don’t need to
render it, which improves performance as typical maps are in large parts empty.
Finally we have to render our map in the main
proc after rendering the
player, so that it is put on top of the player:
For now we don’t have a moving camera, so we use the static game.camera
to
get a fixed rendering position. But for now we can’t even move, so we should
finally do something with the user input and implement a simple physics model.
The end result of this section features our beautiful map rendering:
5. Physics and Collisions
For our game physics we decide to have 50 ticks per second. We will only
calculate the next iteration of the game when a new tick has arrived,
independent of whether our game runs at 60 fps or even 240 fps. Let’s add the
ticks to our main
proc:
That’s our physics framework, but so far the physics does nothing. We can start by adding gravity:
Well, there goes our player. I guess we also need to be able to restart the player now, so that we can see this amazing animation again and again just by pressing r:
This looks exactly like the previous GIF on repeat, so you can imagine it or click on play a few times.
Moving left and right with a and d as well as jumping with space is now easy to implement:
The specific values are determined by trial and error. You can change them around if you have other preferences. Note that we don’t set the player position directly, instead we modify the velocity vector and add that to the position. This is important for collision detection.
While I tried to move around as if the walls and ground had an effect, I
probably failed at deceiving you and you noticed that we still don’t have any
collision detection and handling. This code is largely adapted from Teeworlds.
It works by checking for horizontal and vertical collisions with a tile in
moveBox
, which manipulates the player’s position based on the passed velocity vector.
When a collision occurs the player is moved just out of the tile in the right
direction. For the sake of simplicity in isSolid
we consider every tile other
than air, start, finish
a solid block. Floating point player positions are
converted to indices in the tile map.
getTile
reads a tile from a specified position of the map, making sure not to
over- or underflow:
isSolid
determines whether the player can collide with a tile. As we said
every tile other than air, start and finish are able to be collided with:
A player is on the ground when there is a block below either side of its feet:
Meanwhile testBox
considers the player as an axis aligned boundary box, and
tells us if the player is stuck inside of any solid walls:
moveBox
now tries to apply the velocity vector vel
to the player’s position
pos
. When this causes a collision the code tries to move the player only
along the x axis, then only along the y axis, to find out which side of the
tile the player collided with. If the player did not collide with any of the
sides it hit a corner (that’s the real corner case!):
The moveBox
proc also returns a set of collisions, telling us what kind of
collisions happened in this iteration. We don’t use that information, but it
could be useful if you want to handle collisions in a special way instead of
just pushing the player out of the collided wall.
Finally we can use onGround
and moveBox
:
Jumping is only possible when standing on the ground. Horizontal movement in the air is calculated in a different way than on the ground, simulating different air and ground friction.
6. Camera
Did you see me disappear out of the window? Nice, right?! Well, actually that
probably makes playing the rest of the map rather difficult. So while we can
move nicely, the camera’s position is still fixed. In this section we will only
make the camera move horizontally. With this information you can probably
figure out how to make it move vertically as well if you want to. We only need
to set the camera position when our player moved, right after calling
game.physics()
:
Now the camera always immediately follows the player’s horizontal position. Another approach for the camera is to have it follow the player only once the player leaves the center area of the screen, in this case 200 pixels:
An alternative approach is to have the camera follow the player fluidly. When the player is further away from the center the camera moves towards him faster. You can imagine the camera as being pulled by a rubber band connecting it to the player, the further away they get, the stronger the camera gets pulled to the player:
Choices are difficult so we can just implement all three and you can choose at
compile time using -d:fluidCamera
and -d:innerCamera
:
7. Game State
Now that we can run around all of the map with the camera keeping up with us,
we should give our game a purpose. You might have noticed the light and dark
gray lines at the beginning and end of the map, suspiciously referred to as
start
and finish
respectively. As you can probably guess we will use those
as start and finish lines and record how quickly the player can get from start
to finish:
We’re now storing a Time
object in the Player
, telling us when the player
began playing this round, how he finished last time and what his absolute best
time is. By default the values are initialized to -1
to indicate an invalid
value, otherwise they store ticks.
To format the time for display we use the excellent strfmt library’s string interpolation:
Our game logic works as follows:
- When the player walks through a start tile his time begins
- When the player walks through a finish tile his finish time is set and printed to the terminal. If it is a new best time, so is his best time. The start time is reset.
We need to call the logic
in our main
proc of course:
Now we can play through the map and finally get an output like this on the terminal:
Finished in 00:04:38
8. Text Rendering
It would be much nicer to have the text output with the result in the actual game window instead of on the terminal. For this we will now use SDL_ttf:
What’s happening here is that we render a text with a FontPtr
to an SDL2
surface (stored in RAM), which is then put into a texture (stored in GPU’s
VRAM). This texture is then rendered to the screen at the defined position.
We need to initialize the TTF subsystem and now we also need to pass the
current tick to render
inside of our main
function:
As well as loading our DejaVuSans.ttf font inside of newGame
:
The Game
object now looks like this, including a font
:
This is what the text rendering looks like after finishing the map:
It noticeably doesn’t look all that great. The border of the text looks rugged.
This happens because renderUtf8Solid
does not use alpha blending, which is
expensive. Instead every pixel is either entirely white or entirely
transparent, never anything half-transparent in between. If we had a fixed
background color for the text we could use renderUtf8Shaded
, which takes a
background color. If we want nicer output with dynamic backgrounds we can use
renderUtf8Blended
instead:
This looks better, but would be hard to see with a brighter background. We can draw an outline for our text to fix this, basically by drawing the text twice, once in half-transparent black and once in the proper color on top:
9. Text Caching
If you looked at your CPU usage during this tutorial so far you might have noticed that the game needed nearly no CPU at all, about 3 % for 60 fps on my system. Once the texts are rendered this increases to 20% though. The reason for this is that right now we are regenerating the text textures every single frame, even if it didn’t change from the last frame. If you have fixed texts you can simply save the textures instead of recalculating them. But if you want more flexibility instead you can use a glyph or texture caching system. An example of such a system would be SDL_FontCache.
But instead we can write a little application-specific caching scheme in Nim. The heuristic we use is that mostly a single line in the code base will keep producing the same string, at least for some time. So we cache only a single text rendering for each line that prints something to the screen. That means we don’t have to do any lookups in a cache data structure and we only use a guaranteed constant amount of memory for caching:
A CacheLine
is what we store, a pointer to a texture as well as the texture’s
width and height. For our text rendering we need two of those as we render the
text twice to get the outline effect. The text
is also stored in TextCache
to see if we already have the correct textures cached.
We manipulated renderText
to return a CacheLine
that we can use:
In two passes the text is rendered, if the text is cached from the cache directly, otherwise the old cache entry is removed and replaced with the new textures.
Nim’s metaprogramming allows us to use this seamlessly with a small template. A few days ago I wrote an article about Metaprogramming in Nim if you want to learn more about the powerful side of Nim.
Now each of the three renderTextCached
calls get its own TextCache
assigned, which is then used for the rest of the program execution. Note that
this caching scheme only works under the assumption that there are relatively
few separate lines of code that call renderTextCached
and the ones that do
often render the same text multiple times in a row. Good enough for our use
case.
We also reduced the exactness of the current time format because before it had to be recalculated every single frame. Now we’re back to using 4 % CPU and our final game looks like this:
Building
We can create a platformer.nimble
file to tell
Nimble, Nim’s package manager, how to
build our package:
All the intermediate code states from this article can be compiled with nimble
tests
. We can test the build by running nimble build
, which creates a
platformer
binary that we can run. nimble install
installs the same binary
so that it is available in ~/.nimble/bin
.
But once we run this installed platformer
binary we get an error:
Error: unhandled exception: Could not load image player.png, SDL error: Couldn't open player.png [SDLException]
Actually this makes sense, because we load the files at runtime from the current directory, which can now be any directory. We have two choices for how to solve this:
- Read in all files at compile time and get a binary that depends on no assets
Doing this is pretty simple in Nim as the compiler supports reading arbitrary files at compile time withstaticRead
:
- Define a directory where we store our data assets to load them at runtime. This enables the player to switch them out for custom ones without the need to recompile the binary. And as we’ve seen with the DDNet Skin Database this might be a useful feature. We find the data directory by looking where in the file system our binary is. Let’s implement this and make the old embedded compile time assets optional:
This also requires us to change newMap
to accept a Stream
instead of a filename:
When compiling you can set -d:embedData
like this:
$ nim -d:release c platformer
$ ls -lha platformer
-rwxr-xr-x 1 deen deen 129K Jun 13 14:54 platformer*
$ nim -d:release -d:embedData c platformer
$ ls -lha platformer
-rwxr-xr-x 1 deen deen 888K Jun 13 14:55 platformer*
You can find our final platform game code in the repository.
Now we can submit the repository to the Nimble
packages as a pull request and soon all
Nim developers can install the package right from their terminal with a nimble
install platformer
and play by just running platformer
.
A circle.yml file defines how to run and compile our repository and is used to make sure it keeps building fine whenever changes are made.
Binary Distribution
But Nim developers are probably not the main target group of our game, so ideally we we also want to be able to build binaries for a few common platforms, Linux x86, x86-64 and Windows x86, x86-64 for us. Building for Mac OS X is a bit more involved, but you can check out how DDNet does it.
Of course we could just set up a VM for each system that we want to build for and use the instructions from the start of this article. But that’s tedious and we want the convenience of building on a single machine.
Linux
Note that I’m on Arch Linux, but this should be possible in an analogous way on other Linux distributions:
Building a portable binary for Linux is a pain because of glibc. When you compile on a system with a newer glibc version it might not run on a system with an older one. A common solution is to use a build system with an old Linux install. Alternatively an old Debian chroot can be created with debootstrap. There is also Linux Standard Base which aims to solve this problem, but I haven’t used yet.
A more hacky solution is to create the binary on the new system and check what
symbols exactly are linked against the newer glibc version. In our case I want
everything to use GLIBC_2.2.5
, so I check for anything else:
objdump -T platformer | grep GLIBC | grep -v 2.2.5
0000000000000000 DF *UND* 0000000000000000 GLIBC_2.14 memcpy
Okay, only memcpy
is the problem. We can force the linker to use an old version
of memcpy
and another common problem, realpath
, like this in C code with
inline assembler:
But this would require for us to insert this into every C file that we
generate. Or we abuse the nimbase.h
file and insert it there and compile
with:
$ head -n2 glibc-hack/nimbase.h
__asm__(".symver memcpy,memcpy@GLIBC_2.2.5");
__asm__(".symver realpath,realpath@GLIBC_2.2.5");
$ nim -d:release --passC:-Iglibc-hack c platformer
$ objdump -T platformer | grep memcpy
0000000000000000 DF *UND* 0000000000000000 GLIBC_2.2.5 memcpy
$ objdump -T platformer|grep GLIBC|grep -v 2.2.5
Now our binary works on Linux versions using glibc 2.2.5 or newer. Note that the user still needs SDL2, SDL_image2 and SDL_ttf2 installed.
When you’re linking dynamically and want to distribute shared libraries with
your binary you can compile with nim --passC:-Wl,-rpath,. c platformer
and
put the shared libraries into the same directory as the binary.
At least building for x86 is easy on Linux as long as you’re on x86-64 and have gcc-multilib installed:
$ yaourt -S lib32-sdl2 lib32-sdl2_image lib32-sdl2_ttf
$ nim --cpu:i386 --passC:-m32 --passL:-m32 -d:release c platformer
Windows
Surprisingly it is much easier to build portable binaries for Windows, even from Linux:
$ pacman -S mingw-w64-gcc
$ nim --os:windows --cpu:amd64 --gcc.exe:x86_64-w64-mingw32-gcc --gcc.linkerexe:x86_64-w64-mingw32-gcc -d:release c platformer
$ nim --os:windows --cpu:i386 --gcc.exe:i686-w64-mingw32-gcc --gcc.linkerexe:i686-w64-mingw32-gcc -d:release c platformer
SDL libraries for Windows can be downloaded from the SDL2 website (image, ttf).
Stripping the binaries of symbols with strip -s platformer
is a good idea if
you want to save some space and don’t care about debugging your binary.
Automated Build Script
With all this information we can now write a fully automated release build script, written in Nim as well:
Linux users have to install sdl2, sdl2_image, sdl2_ttf using their package
manager. Windows users get them bundled. Our build script creates this
directory structure when we run it with nim -r c release
:
.
├── platformer_1.0_linux_x86
│ ├── data
│ │ ├── default.map
│ │ ├── DejaVuSans.ttf
│ │ ├── grass.png
│ │ └── player.png
│ └── platformer
├── platformer_1.0_linux_x86.tar.gz
├── platformer_1.0_linux_x86_64
│ ├── data
│ │ ├── default.map
│ │ ├── DejaVuSans.ttf
│ │ ├── grass.png
│ │ └── player.png
│ └── platformer
├── platformer_1.0_linux_x86_64.tar.gz
├── platformer_1.0_win32
│ ├── data
│ │ ├── default.map
│ │ ├── DejaVuSans.ttf
│ │ ├── grass.png
│ │ └── player.png
│ ├── libfreetype-6.dll
│ ├── libpng16-16.dll
│ ├── platformer.exe
│ ├── SDL2.dll
│ ├── SDL2_image.dll
│ ├── SDL2_ttf.dll
│ └── zlib1.dll
├── platformer_1.0_win32.zip
├── platformer_1.0_win64
│ ├── data
│ │ ├── default.map
│ │ ├── DejaVuSans.ttf
│ │ ├── grass.png
│ │ └── player.png
│ ├── libfreetype-6.dll
│ ├── libpng16-16.dll
│ ├── platformer.exe
│ ├── SDL2.dll
│ ├── SDL2_image.dll
│ ├── SDL2_ttf.dll
│ └── zlib1.dll
└── platformer_1.0_win64.zip
Resulting downloads here: Win64, Win32, Linux x86_64, Linux x86
Final Words
Somehow my articles are getting longer and this journey towards writing a simple platform game took a few more explanations than expected. I hope this article wasn’t too long and was instructive and helpful to get an understanding of how to write platform games in Nim with SDL2.
All the material for this article is available in the repository on GitHub. Because I changed around things late into the article I might have made a mistake or missed something. If you find a bug or have a comment you can drop me an email at [email protected].
Discussions on r/programming and Hacker News.