How to make a game engine for WebAssembly: Part 2
(index)
Welcome back to the blog series about making a game engine for WebAssembly. By the end of this series, you’ll be able to make graphically complex games that are playable in web browsers.
Before we start on graphics, though, I’d like to take a few parts to focus on more simple concepts. Graphics are hard, and having a solid understanding of the basics will help us when it comes time to slay the fowl beast known as OpenGL.
Text: an adventure
Back when I was teaching myself C++ development, I decided to make a simple text adventure game before attempting a complicated graphical program. This tends to be standard pedagogy when learning any sort of language, so we’ll do the same here.
Our first game is going to be a text adventure game. Not only is it simple to program, we don’t have to deal with audio or graphics. We can just rely on printing to the console and reading input from the console, and Emscripten makes both of those tasks effortless.
…or so I thought.
As we saw in part one, printing to the console in Emscripten’s default shell file is as simple as putting text into standard output. Surely, I thought, we can just read standard input too, right?
The good news: it works. The bad news: it doesn’t work well.
(Aside: the reason it doesn’t work well is because every time you query standard input, it calls prompt, which brings up an annoying text box that won’t go away until you close it. Whereupon you’ll immediately get hit with another prompt if your app is constantly checking standard input, which ours will. It’s not a fun time.)
So, we’re going to implement our own console in HTML. We’ll use JavaScript to interface with it, and we’ll learn how to call JavaScript functions from WASM and vice versa.
(Technical aside for web nerds: If you’re wondering why we don’t just directly interact with the DOM from WASM, the answer is we can’t, at least as of 2024. I hope this aside is outdated soon…)
Sounds like a lot? It is! But we’ll take things one thing at a time, and I’ll guide you through every step of the process.
(Aside: If you want to do more research into how javascript and webassembly interactions are handled with Emscripten, you can read this document, though it is not needed to complete this tutorial.)
Code file
I’d recommend creating a new folder for this project, maybe with a name like “02 text adventure.”
Create a file called main.cpp, and write the following function:
int main(){
return(0);
}
That’s right: our program does nothing. For now. We’ll make it do something later, but we’ll focus on something else for now.
Shell file
Remember when I gave you a vastly oversimplified model of what happens when you run the Emscripten compiler? I’m about to make it a little less simple.
To make your final HTML file, Emscripten uses a “shell file”. Emscripten will make a copy of this file and insert your C++ code into it. (Keep in mind that I’m still simplifying here, but we have a lot to deal with in this lesson.) Emscripten will use a default shell file if you don’t specify one, and this default shell file was used last time. This time, we’re going to make our own.
Make a new file called shell.html. Write the following into it:
<!DOCTYPE html>
<html>
<head></head>
<body>
<!--Output area-->
<textarea id="output" cols="80" rows="20" readonly autocomplete="off"></textarea>
<br/>
<!--Input area-->
<input id="input" size="80" value=""/>
<!--Emscripten generated code goes here-->
{{{ SCRIPT }}}
</body>
</html>
(In case you don’t know, the <!-- lines are comments.)
I won’t expect you to be an expert in HTML for this series, but some basic knowledge will help. One thing that does need explaining is the {{{SCRIPT}}} text. That isn’t actually HTML, it’s simply a piece of text which Emscripten will replace with “our C++ code”, more or less. (Again, we’re simplifying.)
Now, in your command prompt, run the following command:
emcc main.cpp -s WASM=1 -o index.html - shell-file shell.html
(Aside: It might be a good idea to put the command to compile your projects into a batch file so you don’t have to re-type the above command every time you want to re-compile. The commands are only going to get longer. For my games, I recently switched to making Python scripts that build and run an emcc command, and the build system rabbit hole goes even deeper if you’re into that sort of thing.)
The — shell-file option will tell Emscripten to use our shell file instead of the default one. Now, you can run and connect to a local web server (see part 1 if you need a refresher), and you should see something like this:
This is the simplest “command prompt” style interface I could come up with. Eventually, you will be able to input commands in the bottom area, and the game will print out responses to the top area.
So, let’s go back to writing C++ code.
Writing the header file
As I said before, I’m going to assume you know how to program in C++, so I’ll also assume you know about header files. Make a file called main.h which consists of the following:
#pragma once
#include <string>
//print to the output
extern void print(const char*);
extern void print(std::string);
//print to the output and append a newline
extern void println(const char*);
extern void println(std::string);
This is effectively our own “API” for reading/writing to our console. Now, let’s work on implementing these functions.
EM_JS
EM_JS is a macro Emscripten provides which allows a Javascript function to be defined inside a C file. Functions defined this way can be called from C code.
Make a file called web.cpp and write the following code:
#include "main.h"
#include <emscripten.h>
EM_JS(void, printJS,(const char* toPrint),{
//a javascript function which prints text to our console output
//takes a const char* argument called toPrint, returns void
//get the DOM element with id of "output"
output = document.getElementById("output");
//convert our (UTF8) c string into a javascript string.
output.value += UTF8ToString(toPrint);
//scroll down to view the newly printed text.
output.scrollTop = output.scrollHeight;
});
void print(const char* toPrint){
printJS(toPrint);
}
Because Javascript strings and C strings use different formats (and are stored in different places,) we need to call UTF8ToString to convert our C string to a Javascript string.
Note that UTF8ToString makes a Javascript string, and we don’t need to worry about freeing its associated memory because Javascripts’ garbage collector will take care of that for us. Though, if we go the other way and make a c string from a Javascript string, we need to eventually free that string. But you probably won’t need to do that.
(Aside: if you’re wondering why we’re calling printJs from print instead of defining print using EM_JS: I tried to do the latter, but got an error. One of my beta readers figured out that it’s possible if you use “extern c” in the header file declaration of print, but I’ve chosen not to do that. I’ll save that topic for (slightly) later.)
Once we have print(const char*) done, putting the other three print variants into web.cpp is straightforward:
void println(const char* toPrint){
print(toPrint);
print("\n");
};
void print(std::string toPrint){
print(toPrint.c_str());
}
void println(std::string toPrint){
println(toPrint.c_str());
}
Now, let’s go back to main.cpp and replace our previous code with this:
#include "main.h"
int main(){
println("Hello, world!");
return(0);
}
Now we’re ready to compile, but make sure change your compilation command to include web.cpp
emcc main.cpp web.cpp -s WASM=1 -o index.html --shell-file shell.html
(Aside for those who know about wildcard characters: as of writing, Emscripten doesn’t seem to support them! So instead of typing *.cpp and having the compiler automatically find and compile all our code files, we have to specify each and every one.)
Now, fire up that web server and see the fruits of our labor:
Refresh the page, and you should see the same result. Just so you know, the “autocomplete=off” attribute we put on the <textarea> element is what causes it to properly reset every time you reload the page. You can thank my beta readers for that insight.
Input
Here comes the hard part: We need to call a C function from JS. More precisely: the plan is to call a c function every time the user enters the text into the terminal.
Emscripten provides a way to “wrap” C functions so that we can call them from javascript. The catch is that it has to truly be a C function, which means wrapping our function in an extern block.
So, add the following function to your web.cpp file:
extern "C" {
void onConsoleInput(const char* input){
println(input);
};
}
Right now, we’re just printing out whatever the user types in, but we’ll make it do more in the future.
That’s the first of three things that we need to do. The second task is to modify shell.html.
In shell.html, add the following <script> tag element after the <input> element:
<script>
var Module={
postRun:[function(){
//wrap c function
let onConsoleInput = Module.cwrap("onConsoleInput","null",["string"]);
//every time enter is pressed, call onConsoleInput and clear the text entry box
document.onkeydown = function(e){
if(e.keyCode != 13) return;
onConsoleInput(document.getElementById("input").value);
document.getElementById("input").value = "";
}
}]
}
</script>
Emscripten will call postRun code after our main function finishes running. In practice, this means that the code will run right after we’ve finished initial setup. I’ll explain more in later parts.
For now, just now that the <script> code’s job is to listen for when the enter/return is pressed (which has a keyCode of 13). Every time it’s pressed, the script sends the contents of the input area to our C++ code, then clears the input area.
Now, we need to modify our compiler command once more to add EXPORTED_FUNCTIONS and EXPORTED_RUNTIME_METHODS. According to the docs:
EXPORTED_FUNCTIONS tells the compiler what we want to be accessible from the compiled code (everything else might be removed if it is not used), and EXPORTED_RUNTIME_METHODS tells the compiler that we want to use the runtime functions ccall and cwrap (otherwise, it will not include them).
Our new command is this:
emcc main.cpp web.cpp -s WASM=1 -o index.html --shell-file shell.html -sEXPORTED_FUNCTIONS=_onConsoleInput,_main -sEXPORTED_RUNTIME_METHODS=ccall,cwrap
Now, compile the project, launch a web server, and view the webpage. Click on the box at the bottom and type in text, then press the enter key. You should see whatever you enter getting printed to the screen, just like a terminal emulator.
We now have working text I/O! There’s just one last thing we need to do before we call it a wrap on this part. I want to have a function in main.cpp named processInput that gets called whenever we type in text. We could just rename onConsoleInput to processInput and move it to main.cpp (and you’re welcome to do this,) but I would rather keep all the web I/O functionality contained in its own file.
Add these lines to main.h:
//called whenever the user enters text
extern void processInput(const char*);
In web.cpp, change the extern block to the following:
extern "C" {
void onConsoleInput(const char* input){
processInput(input);
};
}
Finally, add this function into main.cpp:
void processInput(const char* input){
println(input);
//pretend we're running game logic...
println("Pretend this is the game’s text output.");
};
Compile/launch/run to see the results. It should be the same as before, except now we print out extra text each time the user inputs the line. Our code is ready for the next part, where we’ll implement game logic.
We did it!
Sorry this part wasn’t very fun! Next time, we’ll focus on building gameplay code.
Before I go, I’d like to give a special shout out to Misshapen Smiley for providing a lot of excellent feedback on this post.