smhk

Porting a simple SDL2 game to Emscripten

In these notes, we take a simple SDL2 game written in C, which builds on both Linux and Windows 10, and port it to Emscripten so that the game works in the browser. This assumes we have already set up the Emscripten build, which was covered earlier for Windows and Linux. This requires a few changes to the game’s source code, which are:

  • Fixing the compiler errors and warnings.
  • Adding some conditional #ifdef for Emscripten-specific code.
  • Add data (such as images) to the build, so that images display in the browser.
  • Update the main event loop to use the Emscripten emscripten_set_main_loop function, so that we do not cause the browser to freeze.
  • Increase the memory size to permit the game to run.

We’ll cover these points in more detail below.

Our first goal is simply to get a successful build, and our second goal is to ensure that build plays nicely in the browser.

Correctly include SDL2 §

First attempt. Having just set up Emscripten with CMake, let’s try building and see what happens:

$ cmake --build build
[  3%] Building C object src/CMakeFiles/engine.dir/engine.c.o
ports:INFO: retrieving port: freetype from https://github.com/emscripten-ports/FreeType/archive/version_1.zip
ports:INFO: unpacking port: freetype
cache:INFO: generating port: sysroot\lib\wasm32-emscripten\libfreetype.a... (this will be cached in "C:\MyProject\emsdk\upstream\emscripten\cache\sysroot\lib\wasm32-emscripten\libfreetype.a" for subsequent builds)
cache:INFO:  - ok
ports:INFO: retrieving port: harfbuzz from https://storage.googleapis.com/webassembly/emscripten-ports/harfbuzz-3.2.0.tar.gz
ports:INFO: unpacking port: harfbuzz
cache:INFO: generating port: sysroot\lib\wasm32-emscripten\libharfbuzz.a... (this will be cached in "C:\MyProject\emsdk\upstream\emscripten\cache\sysroot\lib\wasm32-emscripten\libharfbuzz.a" for subsequent builds)
root:INFO: building port: harfbuzz
cache:INFO:  - ok
ports:INFO: retrieving port: sdl2_ttf from https://github.com/libsdl-org/SDL_ttf/archive/38fcb695276ed794f879d5d9c5ef4e5286a5200d.zip
ports:INFO: unpacking port: sdl2_ttf
cache:INFO: generating port: sysroot\lib\wasm32-emscripten\libSDL2_ttf.a... (this will be cached in "C:\MyProject\emsdk\upstream\emscripten\cache\sysroot\lib\wasm32-emscripten\libSDL2_ttf.a" for subsequent builds)
cache:INFO:  - ok
ports:INFO: retrieving port: sdl2_image from https://github.com/emscripten-ports/SDL2_image/archive/version_4.zip
ports:INFO: unpacking port: sdl2_image
cache:INFO: generating port: sysroot\lib\wasm32-emscripten\libSDL2_image.a... (this will be cached in "C:\MyProject\emsdk\upstream\emscripten\cache\sysroot\lib\wasm32-emscripten\libSDL2_image.a" for subsequent builds)
cache:INFO:  - ok
In file included from C:\MyProject\src\engine.c:1:
In file included from C:/MyProject/include\engine.h:5:
In file included from C:/MyProject/include/objects.h:8:
In file included from C:/MyProject/include/sprites.h:7:
C:/MyProject/include/common_sdl2.h:13:2: error: "OS missing SDL2 header file includes"
#error "OS missing SDL2 header file includes"
 ^

An error, but that’s no surprise. It fails because the common_sdl2.h header raises a build error:

#ifdef __linux__
    #include <SDL2/SDL.h>
    #include <SDL2/SDL_image.h>
    #include <SDL2/SDL_ttf.h>
#elif _WIN32
    #include <SDL.h>
    #include <SDL_image.h>
    #include <SDL_ttf.h>
#else
    #error "OS missing SDL2 header file includes"
#endif

The purpose of this file is to be a cross-platform header file for including SDL2, since typically SDL2 is included in Linux with #include <SDL2/SDL.h> and on Windows with #include <SDL.h>. Since Emscripten uses the same include style as Linux, we can fix this by using the __EMSCRIPTEN__ define:

#if defined(__linux__) || defined(__EMSCRIPTEN__)
    #include <SDL2/SDL.h>
    #include <SDL2/SDL_image.h>
    #include <SDL2/SDL_ttf.h>
#elif _WIN32
    #include <SDL.h>
    #include <SDL_image.h>
    #include <SDL_ttf.h>
#else
    #error "OS missing SDL2 header file includes"
#endif

Fix build warnings §

Second attempt. Now the SDL2 include is fixed, let’s try again:

$ cmake --build build-em
[  3%] Building C object src/CMakeFiles/engine.dir/engine.c.o
[  7%] Building C object src/CMakeFiles/engine.dir/common_sdl2.c.o
[ 11%] Building C object src/CMakeFiles/engine.dir/coords.c.o
C:\MyProject\src\coords.c:54:13: warning: enumeration value 'COORD_TYPE_LEN' not handled in switch [-Wswitch]
    switch (grid->coordType) {
            ^~~~~~~~~~~~~~~
C:\MyProject\src\coords.c:74:13: warning: enumeration value 'COORD_TYPE_LEN' not handled in switch [-Wswitch]
    switch (grid->coordType) {
            ^~~~~~~~~~~~~~~
2 warnings generated.
...
[ 66%] Building C object src/CMakeFiles/engine.dir/player.c.o
[ 70%] Building C object src/CMakeFiles/engine.dir/screen.c.o
[ 74%] Building C object src/CMakeFiles/engine.dir/sprites.c.o
[ 77%] Building C object src/CMakeFiles/engine.dir/str_pool.c.o
[ 81%] Building C object src/CMakeFiles/engine.dir/str_util.c.o
[ 85%] Building C object src/CMakeFiles/engine.dir/timer.c.o
[ 88%] Building C object src/CMakeFiles/engine.dir/windows_logging.c.o
[ 92%] Linking C static library libengine.a
[ 92%] Built target engine
[ 96%] Building C object app/CMakeFiles/mygame.dir/main.c.o
C:\MyProject\app\main.c:237:67: warning: format specifies type 'int' but the argument has type 'size_t' (aka 'unsigned long') [-Wformat]
                            printf("created obj id=%d type=%d\n", obj_id, obj_type);
                                                   ~~             ^~~~~~
                                                   %zu
1 warning generated.
[100%] Linking C executable mygame.html
[100%] Built target mygame

Looks like Emscripten is strict about handling all cases in a switch statement, and about printf format specifiers. These are easily resolved.

That achieves the first goal, though as we see when we try and actually run the game, a successful build by itself isn’t enough!

Fix image support §

Third attempt. This time, the game builds and runs, but aborts immediately with an error, so we just get a black screen:

SDL_image could not initialise! SDL_image error: PNG images are not supported

This error originated from graphics.c in the game’s own code:

 // Initialise PNG loading
 int imgFlags = IMG_INIT_PNG;
 if (!(IMG_Init(imgFlags) & imgFlags)) {
     printf("SDL_image could not initialise! SDL_image error: %s\n", IMG_GetError());
     success = false;
 } else {
   // ...

I found out here that you have to specify -s SDL2_IMAGE_FORMATS='["png"]' at compilation to enable the image formats you desire.

However, this did not work. It gave this peculiar message when compiling:

cache:INFO: generating port: sysroot\lib\wasm32-emscripten\libSDL2_image_[png].a... (this will be cached in "C:\MyProject\emsdk\upstream\emscripten\cache\sysroot\lib\wasm32-emscripten\libSDL2_image_[png].a" for subsequent builds)
cache:INFO:  - ok

Then and runtime gave the same PNG images are not supported error.

I searched some more, and found here that for Windows you need to throw more quotes at it, e.g. SDL2_IMAGE_FORMATS = "[""png""]". So the correct solution involves fun multi-level escaping in CMakeLists.txt:

set(USE_FLAGS "-sUSE_SDL=2 -sUSE_SDL_IMAGE=2 -sUSE_SDL_TTF=2 -sSDL2_IMAGE_FORMATS=\"[\"\"png\"\"]\"")

We can place this snippet inside our conditional Emscripten section in CMakeLists.txt, since it only applies to Emscripten builds:

if (${CMAKE_SYSTEM_NAME} MATCHES "Emscripten")
    set(USE_FLAGS "-sUSE_SDL=2 -sUSE_SDL_IMAGE=2 -sUSE_SDL_TTF=2 -sSDL2_IMAGE_FORMATS=\"[\"\"png\"\"]\"")
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${USE_FLAGS}")
    set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${USE_FLAGS}")
    set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} ${USE_FLAGS}")
    set(CMAKE_EXECUTABLE_SUFFIX .html)
else()
    find_package(SDL2 REQUIRED)
    find_package(SDL2_image REQUIRED)
    find_package(SDL2_ttf REQUIRED)
endif()

Update main event loop §

Fourth attempt. This time the game builds and runs, and doesn’t abort immediately - that’s progress! Have we achieved the second goal?

Unfortunately not: the browser soon points out that the page has hung, and asks if we would like to kill the tab. This is because the game has reached the main event loop, where it is merrily spinning away forever.

To fix this, we need to convert our SDL2 main event loop into an Emscripten main event loop.

This means removing the while loop and replacing it with emscripten_set_main_loop which calls a main loop function, e.g.:

static bool quit = false;
static SDL_Event e;

static void main_loop();

int main(int argc, char *argv[]) {
    init_all_the_things();

#ifdef __EMSCRIPTEN__
    emscripten_set_main_loop(main_loop, 0, true);
#else
    while (!quit) {
        main_loop();
    }
#endif
}

void main_loop() {
    while (SDL_PollEvent(&e) != 0) {
        /* User closes window. */
        if (e.type == SDL_QUIT) {
            quit = true;
        } else if (/* ... */) {
            /* Etc. */
        }
    }
}

The first argument (main_loop) is the function to be called at the defined number of frames per second.

The second argument (0) is the number of frames per second (FPS). Setting it to 0 is a special value which tells the browser to decide the FPS, which is recommended.

The third argument (true), documented as simulate_infinite_loop, is a little strange. Setting it to true means no code after the call to emscripten_set_main_loop will ever run, in which case you can think of the function call as a while (true) loop that you can never break out of. Setting it to false means that the code execution does carry on. See the official documentation for more details:

Include data (assets) §

Fifth attempt. We can now run our game in the browser without it locking up, however we just have a blank screen. This is because none of the game’s data (or assets), such as images or fonts, have been included.

For starters, let’s add an optional Emscripten section in app/CMakeLists.txt that includes the font used by the game:

if (${CMAKE_SYSTEM_NAME} MATCHES "Emscripten")
   set_target_properties(mygame PROPERTIES LINK_FLAGS "--preload-file=data/fonts/VeraMono.ttf")
endif()

Note that the data directory must be placed in build-em/app/, since --preload-file looks for the file relative to the target (in this case, mygame, which is in build-em/app/).

This works! No sprites, but the fps counter shows with the font.

Now let’s add the spritesheet:

set_target_properties(mygame PROPERTIES LINK_FLAGS "--preload-file=data/fonts/VeraMono.ttf --preload-file=data/sprites/spritesheet.png -s")

Now we have sprites!

Memory error §

As a bonus error, by increasing the size of an array I managed to trigger the following error, which appeared in the JavaScript console in the browser:

# Uncaught RuntimeError: Aborted(Cannot enlarge memory arrays to size 17739776 bytes (OOM). Either (1) compile with  -s INITIAL_MEMORY=X  with X higher than the current value 16777216, (2) compile with  -s ALLOW_MEMORY_GROWTH=1  which allows increasing the size at runtime, or (3) if you want malloc to return NULL (0) instead of this abort, compile with  -s ABORTING_MALLOC=0 )

As the error suggests, this can be fixed by adding -s ALLOW_MEMORY_GROWTH=1 to set_target_properties in the CMakeLists.txt:

set_target_properties(mygame PROPERTIES LINK_FLAGS "--preload-file=data/fonts/VeraMono.ttf --preload-file=data/sprites/spritesheet.png -s ALLOW_MEMORY_GROWTH=1")

You can also use -s INITIAL_MEMORY=X if you have an accurate understanding of how much memory your game needs.

Alternatively, you may change your game to reduce the amount of memory it requires!

Wrap-up §

And with that, the game builds and is playable in the browser! That’s the second and final (for now) goal achieved.

There are further improvements that could be made, such as automating how assets in the data/ directory are pre-loaded, but this was a good first pass.