glfighters return! porting a 23-year-old game to the web
TL;DR I ported GLFighters, a game written for Classic Mac OS in 2001, to WebAssembly!
You can play it online at: devnonsense.com/GLFighters-SDL
Table of contents
Background
The first uDevGames contest was announced in July 2001. The small community of Macintosh game developers I’d joined began working feverishly and by October had produced thirty-four entries. One of these was a game called GLFighters by David Rosen.
David would go on to start his own video game company, Wolfire Games. Asked recently about GLFighters, David connected this early work to some of Wolfire’s most beloved games:
I started a sequel to GLFighters that was a bit more like Super Smash Bros, but I was having so much fun jumping around in 3D that I turned the robot soldier character into a rabbit, and I was having so much fun kicking ragdolls around that it became a martial arts action game called Lugaru: The Rabbit’s Foot!. That game and its sequel Overgrowth still have a lot in common with GLFighters, especially in its local multiplayer mode.
But that would all come later. At the time, GLFighters blew me away. How did he make this in just eight weeks? I remember wondering. It was a 3D game with swords, lasers, grenades, jetpacks, AI, sound effects, a built-in level editor. Mostly implemented in a giant file called “Lesson24.cpp” with nearly ten thousand lines of code.1 It was messy, joyful, ambitious – everything I loved about the indie Mac gamedev scene.
GLFighters is still available on the Wolfire website today, but I couldn’t find an emulator fast enough to run it. Perhaps it been lost forever, bit-rotted beyond repair, like so much other software from that era.
The game was written for Classic Mac OS,2 using APIs like Toolbox and DrawSprockets that no longer exist. It was built for the PowerPC architecture, the predecessor of the predecessor of Apple’s current ARM-based chips. For graphics, it used OpenGL 1.2, all that glBegin()
and glEnd()
immediate-mode silliness, deprecated since 2009.
None of this was likely to run on a modern computer. But the source code was still available online,3 so maybe, just maybe, it could be salvaged.
Over the next two weeks, I successfully ported the game to Linux and then to WebAssembly. Incredibly, you can now run GLFighters – a game written twenty-three years ago for a platform that no longer exists – on any computer with a recent web browser.
Seriously, you can play it right now! devnonsense.com/GLFighters-SDL
In this post, I’ll share the adventure (and many technical challenges) of porting this game from Classic Mac OS to the web.
Porting Strategy
Even as a teenager and self-taught programmer, David knew the code was complicated. In the README, he described it as “cluttered” and “unintelligible”, and in a postmortem published shortly after the contest, he admitted, “when I look at my code I can figure out what’s going on – barely.”
I had no desire to untangle this Gordian knot. But since it was mostly platform-independent
So the plan was:
- Port the window/keyboard/sound/file code from Classic Mac OS APIs to Simple DirectMedia Layer (SDL), a cross-platform toolkit for building games.
- Get the graphics code working on Linux, whose drivers would (I hoped) support OpenGL 1.2.
- Compile the code to WebAssembly using emscripten.
I wasn’t at all sure this was going to work.
Linux Port
Flipped textures
Rather than trying to port the entire game at once, I started with some small prototypes. First, just open a window and render a triangle. Then play a sound, process keyboard input, and so on.
Things were going well until I tried to load and render the game textures. This involved replacing the custom TGA image loader from the original code with SDL_Image. It worked… kind of.
Okay, a few things wrong. The helmet color was supposed to be red, not blue. Swapping the red and blue color channels fixed that.
Also, the text at the bottom was supposed to be “0 1 2 3 4 5”. Looking carefully at the font texture, I guessed the problem was that the texture was flipped vertically:
So I added some code to swap rows of pixels. Much better!
Illegal instruction
Once all of the platform-specific code had been replaced, the game compiled successfully. But on startup the program would immediately crash with exit code 132 and this error:
Illegal Instruction (core dumped)
With some guesswork, I traced the problem to a function with this signature:
int CheckPaths(int whichguy, int num);
Although the function prototype says it returns an int
, the function has no return
statements. This is undefined behavior!
Compilers have gotten much stricter about UB since 2001. Not only does GCC emit a warning, it also injects an illegal instruction into the generated machine code to trigger a crash. After changing the return type to void
, the crashes stopped.
File loading
GLFighters used custom file formats for animations and level maps.4 The original code used Classic Mac OS’s FSRead
to load data, like this:
long lLongSize = sizeof( long );
int localframenum;
// ...
FSRead(sFile, &lLongSize, &localframenum);
Translating this directly to stdio
produced some bizarre results, like animations claiming to have 16645304218600603648 frames. The original game ran on a PowerPC Mac, which used a 32-bit big-endian processor. On 32-bit machines, sizeof(long)
is only 4 bytes! After modifying the code to load exactly 4 bytes and swapping the byte order to little-endian, the animation and map files loaded correctly.
Random SEGFAULT
The game would SEGFAULT seemingly at random. By rebuilding with debug symbols and digging through core dumps, I traced the crash to the initialization of the player start position:
int startplacex[17];
int startplacey[17];
// ...
randomint=RangedRandom(0,15)+7;
guyx[0]=startplacex[randomint]*10-590;
guyy[0]=(startplacey[randomint]-39)*-20+.5;
When RangedRandom(0,15)
returned a value greater than 9, the code would attempt to read past the end of the startplacex
array.
I don’t know how this behaved on Classic Mac OS, but Linux was having none of it.
So I guess you could call these pseudo-random SEGFAULTs. In any event, constraining randomint
within the array bounds stopped the crashes.
Sound sampling rate
For sound playback, I replaced the Macintosh Toolbox calls with SDL_mixer. There was a problem, though, which took me a while to notice. GLFighters had three different “lightsaber” sound effects, but in the game these all sounded identical.
Each of the lightsaber AIFF files had the same original sound data, with pitch variations achieved through playback rate manipulation. The problem was that SDL_mixer enforces a fixed sample rate for the entire audio channel. To resolve this, I resampled each sound file to 48 kHz using ffmpeg.
Z-Fighting
Some geometry would flicker in and out as the camera moved. Look at the ropes and jetpacks in the two images below:
The issue was in the projection matrix setup:
gluPerspective(fov, (GLfloat)width / (GLfloat)height, 0.1f, 1000000.0f);
The last two parameters (zNear
and zFar
) define the boundaries of the view frustum. With such a large range (0.1 to 1,000,000), there wasn’t enough floating-point precision in the depth buffer to properly resolve which fragments were in front. This caused “z-fighting,” where polygons at similar depths would randomly appear in front of each other.
Setting zNear
to 10.0 and zFar
to 2000.0 gave the depth buffer enough precision to correctly determine polygon ordering.
Skybox seams
Finally, the skybox textures had visible seams:
I’m not sure exactly why this happens, but changing the GL_TEXTURE_WRAP
setting from GL_CLAMP
to GL_CLAMP_TO_EDGE
fixed it.
This is listed as a “common mistake” on the khronos.org OpenGL wiki, which puts it bluntly: “Never use GL_CLAMP
; what you intended was GL_CLAMP_TO_EDGE
. Indeed, GL_CLAMP
was removed from core GL 3.1+, so it’s not even an option anymore.” Good to know!
Linux port working!
After about a week of effort, the Linux port was more-or-less working. Onward to WebAssembly!
WebAssembly Port
Emscripten
For the WebAssembly port, I planned to use a tool called emscripten. Emscripten provides a configured LLVM toolchain for compiling C/C++ to WebAssembly, along with runtime libraries that translate SDL and OpenGL calls to their browser-equivalent APIs (such as WebGL for rendering).
Emscripten’s documentation was excellent. It took only about 30 minutes to install the toolchain and recompile the game to WebAssembly. But would it work?
TGA files fail to load
The SDL_image library from emscripten was supposed to load TGA image files, but instead immediately failed with an error. I still wasn’t sure if the game was going to work at all, so I temporarily disabled texture loading and rendering. All the triangles would be white and gray, but at least the game could start.
Page unresponsive
Chrome showed the emscripten “loading” animation, but got stuck with a “Page Unresponsive” error.
Like many games, GLFighters runs a giant while (!gQuit) { ... }
loop to process input, execute the game logic, and render each frame.
This doesn’t cooperate well with the browser, which needs CPU cycles to run its own event loop.
The solution was to register a callback function with emscripten_set_main_loop
,
which the browser could invoke periodically within its own event loop
With this change, the game ran without any complaints from Chrome.
Legacy OpenGL broken
Emscripten supports OpenGL ES 2.0 and 3.0. Unfortunately, GLFighters uses OpenGL 1.2, a much older standard that emscripten does not directly support. Emscripten has an experimental “legacy” OpenGL mode, but when I tried it everything rendered as dark shadows. So I used another library called gl4es that provides a compatibility layer between OpenGL 1.2 and the newer OpenGL ES 2.0 APIs. With only a few short hours of Makefile yak shaving, GLFighters was rendering in the browser!
Slow motion
At first, the game was slow. Really, really slow:
This didn’t make much sense. The original game ran on a G4 400MHz machine without a graphics card and less than a gigabyte of RAM. After twenty-three years of Moore’s Law the game should have run easily on any modern computer, even in a browser.
Profiling the code, I saw that most of the CPU time was spent rendering the models.
This wasn’t too surprising, because GLFighters used OpenGL immediate mode everywhere. The models were rendered using code like this:5
void JetPack( void );
void JetPack( void )
{
glBegin( GL_TRIANGLES );
glTexCoord2f( 0.294642955, 0.653696477 );
glNormal3f( -0.763544798, 0.481715113, 0.430058122 );
glVertex3f( -0.458499968, 0.899999976, 0.099999994 );
glTexCoord2f( 0.205357224, 0.155642018 );
glNormal3f( -0.964205086, 0.000000000, -0.265157640 );
glVertex3f( -0.458499968, -0.219999999, -0.099999994 );
glTexCoord2f( 0.294642717, 0.163424119 );
glNormal3f( -0.920064509, 0.000000000, 0.391766787 );
glVertex3f( -0.458499968, -0.219999999, 0.099999994 );
// ....
glEnd();
}
So gl4es needed to copy those vertex, texture, and normal coordinates to GPU memory every single time it rendered the model. Fixing it seemed straightforward: modify the the code to use the newer “vertex buffer objects” OpenGL API to send the data once.
And with that optimization… it was still slow. But this time the profiler didn’t show any CPU bottlenecks. So what happened?
Remember earlier when I registered a callback function for emscripten to invoke from the browser event loop? When making this change, I forgot to update the “frames per second” calculation to account for the time between callbacks. The animation code thought it was running at over 500 frames per second, so it drastically reduced the distance each object traveled per frame. After updating the FPS calculation, the game resumed its normal speed.
Fixing texture loading
I gave up trying to get SDL_Image to load TGA files. As far as I can tell, TGA loading is simply not implemented in the SDL_image library provided by emscripten. So instead I converted all images to BMP format, which emscripten’s SDL implementation could load.
Pink skybox textures
Most of the textures displayed correctly, but something went very wrong with the skybox:
Scattered throughout the rendering code were calls like these:
glColor4f(255.0, 255.0, 255.0, 1.0);
The arguments are the red, green, blue, and alpha components of the color, which are supposed to be in the range [0.0, 1.0]
. I guess the OpenGL drivers on Mac OS 9 and Linux clamped values outside this range, but gl4es did… something else? In any event, replacing 255.0
with 1.0
everywhere eliminated that awful pink:
Power-of-two texture tiling
The marble and wood background textures, which were 96x96 pixels, failed to tile properly in WebGL:
This was due to WebGL’s requirement that texture dimensions be powers of two. Resizing the textures to 64x64 fixed the tiling:
Broken background lighting
Emscripten enabled stricter C++ warnings, which caught a subtle rendering issue in this code:
void glDrawBigCube(float xWidth, float yWidth, float zWidth, int tesselation, float movement) {
int normallength = .3;
// ...
See the bug? LLVM did!
em++ -I./wasm/gl4es-v1.1.6/include -c game.cpp -o game.o
game.cpp:1668:22: warning: implicit conversion from 'double' to 'int' changes value from 0.3 to 0 [-Wliteral-conversion]
1668 | int normallength = .3;
| ~~~~~~~~~~~~ ^~
In OpenGL, the program can control how surfaces are illuminated by specifying “normal” vectors and positioning lights.
Implicit conversion of .3
to an int
meant that the normal length became 0
, which messed up lighting calculations for the background.
Things look much brighter with correct lighting!
Source Code and Demo
With David’s permission, I published source code to github.com/wedaly/GLFighters-SDL under the MIT license.
The WebAssembly port of GLFighters is playable online at devnonsense.com/GLFighters-SDL. Please try it out!
It amazes me that a game written twenty-three years ago can still be played today on a modern computer. Although Classic Mac OS has faded into the past, GLFighters lives on!
“Lesson 24” is almost certainly derived from one of the tutorials on nehe.gamedev.net, a popular website for learning OpenGL. ↩︎
If I remember correctly, David was one of the last people in the idevgames.com forum to switch from Mac OS 9 to Mac OS X, because Mac OS 9 was faster on his machine. ↩︎
I thought all copies of the source code had been lost, but thankfully someone uploaded it to Macintosh Repository in 2020. ↩︎
The animations were created by a program called Super Duper Character Animator that David wrote himself. ↩︎
David didn’t write 15K lines of rendering code by hand. He used a 3D modeling program called Meshwork, then converted the models to C code using another program called GLSee. ↩︎