Porting a C game to WebAssembly
Have you got an SDL game you want to port to WebAssembly? Maybe you even want touch controls for mobile users? It’s not too bad, but I’m documenting it here because a there are a few curveballs.
The initial stuff:
You’ll need to wrap your main loop (the one that is probably inside of a while (!game.done)
, or something similar)
inside of emscripten_set_main_loop
, mine looks like this:
#ifdef __EMSCRIPTEN__
emscripten_set_main_loop(main_game_loop, 0, 1);
#endif
#ifndef __EMSCRIPTEN__
while (!game.done) {
main_game_loop();
}
#endif
At the top of that file you’ll need to import emscripten.h
too:
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif
That’s the easy part. The next part may not be necessary for all audio formats,
but it is the intended order of SDL calls. Make sure that Mix_OpenAudio
is
called before Mix_Init
.
The bad way:
void load_and_play_music(Game* game)
{
int flags = MIX_INIT_OGG;
int initted = Mix_Init(flags);
if ((initted & flags) != flags) {
printf("Mix_Init: Failed to init required ogg support!\n");
printf("Mix_Init: %s\n", Mix_GetError());
// We can still continue without audio. :^(
} else {
if (Mix_OpenAudio(44100, MIX_DEFAULT_FORMAT, 2, 2048) == 0) {
Mix_VolumeMusic(MUSIC_VOLUME);
game->music = Mix_LoadMUS(MUSIC_INTRO_FILE);
if (!Mix_PlayingMusic()) {
Mix_PlayMusic(game->music, -1);
}
} else {
SDL_LogError(SDL_LOG_CATEGORY_ERROR, "Error initializing SDL_mixer: %s\n", Mix_GetError());
// No need to exit, we just play without sound.
}
}
}
The good way:
void load_and_play_music(Game* game)
{
if (Mix_OpenAudio(44100, MIX_DEFAULT_FORMAT, 2, 2048) == 0) {
int flags = MIX_INIT_OGG;
int initted = Mix_Init(flags);
if ((initted & flags) != flags) {
printf("Mix_Init: Failed to init required ogg support!\n");
printf("Mix_Init: %s\n", Mix_GetError());
// We can still continue without audio. :^(
} else {
Mix_VolumeMusic(MUSIC_VOLUME);
game->music = Mix_LoadMUS(MUSIC_INTRO_FILE);
if (!Mix_PlayingMusic()) {
Mix_PlayMusic(game->music, -1);
}
}
} else {
SDL_LogError(SDL_LOG_CATEGORY_ERROR, "Error initializing SDL_mixer: %s\n", Mix_GetError());
// No need to exit, we just play without sound.
}
}
This is counter to how you’ll be used to initializing most SDL libraries. If you run Mix_Init
first,
you will get an OGG Support Not Available
error.
And the last part (minus getting the right compile flags), is adding actual touch controls. You can check Basque directly for the full implementation here, but here’s the basic JS and HTML I’m using for 4 arrow keys:
<div class="controller">
<button
class="controls"
style="width: 70%"
data-key="ArrowUp"
data-which="38"
>
⇧
</button>
<button
class="controls"
style="width: 35%"
data-key="ArrowLeft"
data-which="37"
>
⇦
</button>
<button
class="controls"
style="width: 35%"
data-key="ArrowRight"
data-which="39"
>
⇨
</button>
<button
class="controls"
style="width: 70%"
data-key="ArrowDown"
data-which="40"
>
⇩
</button>
</div>
// Thanks to @juj for some hints on this issue as to how to get this working:
// https://github.com/emscripten-core/emscripten/issues/3614#issuecomment-142032269
var create_and_fire_event = function (event, type) {
var control_event = new Event(type, { bubbles: true });
control_event.code = event.target.dataset.key;
control_event.key = event.target.dataset.key;
control_event.keyCode = event.target.dataset.which;
control_event.which = event.target.dataset.which;
Module.canvas.dispatchEvent(control_event);
};
Module.postRun.push(function () {
var transform_multiplier = 0.75;
var minimum_transform_multiplier = 0.6;
while ((Module.canvas.width * transform_multiplier) > window.innerWidth && transform_multiplier > minimum_transform_multiplier) {
transform_multiplier = transform_multiplier - 0.05;
}
Module.canvas.parentNode.style.transform = `scale(${transform_multiplier})`;
var margin_removal = (window.innerWidth * (transform_multiplier > minimum_transform_multiplier ? transform_multiplier - 0.1 : transform_multiplier)) / 2;
Module.canvas.parentNode.style.margin = `-${margin_removal * 0.15}px -${margin_removal}px`;
Array.from(document.querySelectorAll('.controls')).forEach(
function (control) {
if ('ontouchstart' in document.documentElement) {
// Mobile:
control.addEventListener('touchstart', function (
event
) {
create_and_fire_event(event, 'keydown');
});
control.addEventListener('touchend', function (
event
) {
create_and_fire_event(event, 'keyup');
});
} else {
// Desktop:
control.addEventListener('click', function (event) {
create_and_fire_event(event, 'keydown');
window.setTimeout(function () {
create_and_fire_event(event, 'keyup');
}, 100);
});
}
}
);
});
There’s some fancy canvas resizing in there to make things look OK on a variety of screens, but the key takeaways are:
- Place everything in
Module.postRun
. - Dispatch events to
Module.canvas
. - Use a standard
Event
type,KeyboardEvent
never seemed to work in my testing.
Lastly, here’s my make
command that I use for a release build:
wasm: source/*
$(EMCC) --shell-file wasm/$(TITLE)_shell.html -O3 --closure 1 -s USE_SDL=2 -s USE_SDL_IMAGE=2 -s SDL2_IMAGE_FORMATS='["png"]' -s USE_SDL_MIXER=2 -s SDL2_MIXER_FORMATS='["ogg"]' -s USE_SDL_TTF=2 -s ALLOW_MEMORY_GROWTH=1 -s INITIAL_MEMORY=$(WASM_TOTAL_MEMORY) -s TOTAL_STACK=$(WASM_STACK_MEMORY) -s WASM=2 --preload-file assets $(FLAGS) -I $${HOME}/code/emsdk/upstream/emscripten/system/include/ -I $${HOME}/work/$(TITLE)/$(TITLE)/emsdk/upstream/emscripten/system/include/ -I /builds/ryanpcmcquen/$(TITLE)/emsdk/upstream/emscripten/system/include/ source/$(TITLE).c -o wasm/$(TITLE).html
Happy hacking!