A Sample Indie Game Dev Project with CMake, SDL2, and Emscripten
I've put together a sample project that indie game developers can reference to bootstrap together SDL2 and Emscripten, targeting both the web and a cross-platform desktop environment. The project illustrates a modern (2025) approach, using CMake build scripts, and pulling in the SDL_image, SDL_ttf, and SDL_mixer libraries for the full suite of functionality that a typically game will need.
You can find the sample project over on my cmake-testing-grounds repository, in the sdl2-emscripten folder.
Why SDL2?
As of mid-December 2025, the core SDL library has had a version 3 release for quite a few months. The SDL_mixer library, however, lags behind with no version 3 release tag as of yet. The SDL_mixer issue tracker shows that the SDL team is extremely close. Once they do release it, I plan to revisit this project to create an SDL 3 version.
I also chose CMake version 3.24, and C++ 17 as the baseline for the sample project, in order to reach as wide of an audience as possible while still keeping reasonably current.
What is Emscripten?
Emscripten is a compiler toolchain that you can use to compile C++ source code into WebAssembly. If you're new to WebAssembly, a good analogy is to think of it as Java byte-code but for web browsers. It's a portable binary format that runs in a virtual machine inside the browser. Emscripten provides direct support for many of the libraries used in game development, such as SDL, OpenAL, and WebGL.
The Emscripten website provides clear installation instructions over on their website.
SDL2 for Desktop vs. Emscripten
The sample project illustrates how to write a CMake build script that can target both a desktop build, and a web-based build. The Emscripten toolchain provides a native WebAssembly port for all SDL2 libraries. This means that your web-based build doesn't actually need to link to the standard SDL2 libraries. This is why the sample project wraps the SDL2 dependency build script in a conditional if(NOT EMSCRIPTEN) block:
cmake_minimum_required(VERSION 3.24)
project(Sdl2EmscriptenExample
VERSION 1.0
LANGUAGES CXX
)
if(NOT EMSCRIPTEN)
add_subdirectory(dependencies/sdl2)
endif()
add_subdirectory(src/main)
A typical game's CMake build script targeting the desktop will include a target_link_libraries statement similar to the following, when linking to SDL2 (for static linkage):
target_link_libraries(
Sdl2EmscriptenExample PRIVATE
SDL2::SDL2-static
SDL2_image::SDL2_image-static
SDL2_mixer::SDL2_mixer-static
SDL2_ttf::SDL2_ttf-static
)
But because the Emscripten toolchain provides a native SDL2 port, the web-based build can instead provide instructions to CMake using compiler and linker options. The following example will provide support for PNG and JPG image formats; as well as the OGG audio format. You can find a complete list of compiler settings on the Emscripten website. Note that while most of the options listed only apply to target_link_options, the -sUSE_SDL and -SSDL2... options in particular also apply to target_compile_options.
target_compile_options(
Sdl2EmscriptenExample PRIVATE
"-sUSE_SDL=2"
"-sUSE_SDL_IMAGE=2"
"-sUSE_SDL_MIXER=2"
"-sUSE_SDL_TTF=2"
"-sSDL2_IMAGE_FORMATS=[\"png\",\"jpg\"]"
"-sSDL2_MIXER_FORMATS=[\"ogg\"]"
)
target_link_options(
Sdl2EmscriptenExample PRIVATE
"-sUSE_SDL=2"
"-sUSE_SDL_IMAGE=2"
"-sUSE_SDL_MIXER=2"
"-sUSE_SDL_TTF=2"
"-sSDL2_IMAGE_FORMATS=[\"png\",\"jpg\"]"
"-sSDL2_MIXER_FORMATS=[\"ogg\"]"
)
The WebAssembly virtual file system
Most games will need to package textures, fonts, sound effects, and music with their build. When targeting a desktop build, a CMake build script is likely to include a pair of add_custom_target and add_dependencies statements similar to the following:
add_custom_target(
CopySdl2EmscriptenExampleResources ALL
COMMAND ${CMAKE_COMMAND} -E rm -rf
"$<TARGET_FILE_DIR:Sdl2EmscriptenExample>/resources"
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_SOURCE_DIR}/src/resources"
"$<TARGET_FILE_DIR:Sdl2EmscriptenExample>/resources"
)
add_dependencies(Sdl2EmscriptenExample CopySdl2EmscriptenExampleResources)
This example will perform a fresh-copy of all files under src/resources, to the resources folder in the target build.
For a web-based game, it is an option to load assets from URLs. But doing so would require writing both JavaScript code (to fetch the assets) and C++ code (to convert the bytes into SDL_Texture*, TTF_Font*, Mix_Chunk*, or Mix_Music* objects) to co-ordinate the whole thing. You may have reason to want to use this approach, but it falls outside of the scope of this article.
WebAssembly provides a virtual file system that allows you to access files in your C++ code as if they're in a typical folder structure. In your CMake build script, you can pass the --preload-file option to a target_link_options statement to include your assets in the Emscripten build. The value passed to this option is split by the @ symbol, with the source folder on the left, and the target folder on the right:
target_link_options(
Sdl2EmscriptenExample PRIVATE
"--preload-file" "${CMAKE_SOURCE_DIR}/src/resources@resources"
)
The Emscripten Build output files
Emscripten and CMake will produce three separate files for your build:
- A
.wasmfile that contains the web browser compatible byte-code. - A
.datafile that contains the virtual file system data. - A
.jsfile that bootstraps the WebAssembly byte-code together with the<canvas>element on your webpage.
A Custom HTML Landing Page
Emscripten can optionally provide a default HTML file packaged with your build, using a set_target_properties statement similar to the following:
set_target_properties(
Sdl2EmscriptenExample PROPERTIES
SUFFIX ".html"
)
However, you're likely to want to create your own HTML landing page for the web-based version of your game. Your HTML page must incorporate a <script> tag referencing the .js file, and it must do so in a way that ensures there are no race conditions. Put in more plain language, you want this script tag to be loaded after the <canvas> element exists on the page.
Current versions of Emscripten reference a globally-scoped variable named Module, and different features or library ports expect the <canvas> element on the page to be given the exact id attribute value canvas. It requires you to define the canvas element by setting the Module.canvas variable, but I still found places in the JavaScript code that are hard-coded to using document.getElementById("canvas").
If you're coding your custom HTML page in a modern approach, you're likely using JavaScript modules in order to avoid polluting the global variable space. So with Emscripten, you'll want to be careful. The way I chose to deal with this on the project release page is something like the following:
<script type="text/javascript">
// Important: The "Module" variable must have global scope
var Module;
</script>
<script type="module">
// Important: The canvas id *must* be exactly "canvas"
let canvasElement = document.getElementById("canvas");
function documentReady(eventHandler) {
if (document.readyState !== "loading") {
eventHandler();
} else {
document.addEventListener("DOMContentLoaded", eventHandler);
}
}
documentReady(initializePage);
function initializePage() {
// Important: Must reference the globally-scoped "Module" variable
// Don't put var or let in front of this!
Module = {
canvas: canvasElement,
};
// Important: Wait until the document has fully loaded before appending the script tag
var gameScript = document.createElement("script");
gameScript.type = "text/javascript";
gameScript.src = "Sdl2EmscriptenExample.js";
document.body.appendChild(gameScript);
}
</script>
Emscripten's Impact on your Game Loop
Emscripten provides a C function named emscripten_set_main_loop_arg, along with several variations, that can be used to bootstrap your game loop in your source code. This particular function accepts four arguments:
func: A C function that performs all logic needed for a single frame of execution in your game loop.arg: A pointer to a user-defined object, which should encapsulate your game state.fps: Number of frames per second thatfuncwill be called. It is recommended to pass0here, which causes the runtime environment to userequestAnimationFrameinstead.simulate_infinite_loop: Boolean value indicating that an exception will be thrown to stop execution.
If your game plans to support both a desktop and web-based build, you'll want to bootstrap your game loop differently for each path, using the __EMSCRIPTEN__ preprocessor macro. The sample project tackles this by defining a unified gameLoop C function that accepts a neutral void* userData argument. This must be type-cast into whatever structure encapsulates the entirety of your game's state. The relevant pattern is shown below.
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#endif
// ... later in main.cpp ...
struct GameUserData {
GameTimer timer;
bool gameIsRunning = true;
GopherGame game;
};
static void gameLoop(void* userData) {
SDL_Event event;
GameUserData* gameUserData = static_cast<GameUserData*>(userData);
std::chrono::duration<double> timerDelta = gameUserData->timer.tick();
while (SDL_PollEvent(&event)) {
if (event.type == SDL_QUIT) {
gameUserData->gameIsRunning = false;
}
else {
gameUserData->game.handleEvent(event);
}
}
if (gameUserData->gameIsRunning) {
gameUserData->game.updateState(timerDelta);
gameUserData->game.render();
}
}
static bool runGame() {
// Scope the game object here, to ensure it loses scope before calling shutdownSdl()
GameUserData gameUserData;
bool result = gameUserData.game.isInitialized();
if (result) {
#ifdef __EMSCRIPTEN__
emscripten_set_main_loop_arg(gameLoop, &gameUserData, 0, true);
#else
while (gameUserData.gameIsRunning) {
gameLoop(&gameUserData);
}
#endif
}
return result;
}
int main(int argc, char* argv[]) {
int result = 0;
InitSdlResult initSdlResult = initializeSdl();
if (sdlInitialized(initSdlResult)) {
if (!runGame()) {
SDL_Log("Failed to initialize the game");
result = -1;
}
}
else {
SDL_Log("Failed to initialize SDL");
result = -1;
}
shutdownSdl();
return result;
}
Invoking the CMake Emscripten build
For the sample project, you can invoke the web-based build on the command line. You first need to ensure it can find the Emscripten environment. On Windows, for developers used to Visual Studio, you'd open the Developer Command Prompt and do something like the following:
path\to\emsdk\emsdk_env
emcmake cmake -B out/build/web-debug -G "Ninja"
cmake --build out/build/web-debug
Conclusion
If you're struggling to get your first C++ web-based game going, the CMake + SDL2 + Emscripten sample project should hopefully help get you through the process. You can play the Hit the Gopher! game over on the project's release page.
As mentioned earlier, once the SDL_mixer library has an official version 3 release tag, I plan to revisit this project to use the full SDL3 stack.
I also plan to write follow-up articles discussing other WebAssembly / Emscripten topics, such as dealing with window resize events, loading asset files from URLs, and handling both mouse and touch events.