Linking is not running: a C++ OpenGL game in the browser
The side-scrolling game on this site was a
Computer Graphics assignment: C++, immediate-mode OpenGL, GLUT, an arena read
from an SVG file. Porting it was the opposite of the
tic-tac-toe port. There, a
1.4 MB emulator runs a 3 KB DOS program. Here there is no emulator. The
181,051 bytes of jogo.wasm are my C++, compiled by Emscripten.
Getting it to compile took one command:
em++ -std=c++11 -O2 main.cpp arena.cpp tinyxml2.cpp character.cpp \
hero.cpp enemy.cpp shot.cpp \
-sLEGACY_GL_EMULATION=1 -lGL -lglut \
--preload-file arena_teste.svg -o jogo.js
It linked clean. I almost wrote down “immediate-mode OpenGL works”. Then I opened it in a browser, and it died on the first frame:
Aborted(unsupported immediate mode 9)
Mode 9 is GL_POLYGON, which the game uses to draw every character’s head.
Emscripten’s emulation of old-style OpenGL doesn’t implement it. Swapping in
GL_TRIANGLE_FAN draws the same thing for a convex shape, so it’s a one-word
patch.
The patch didn’t bother me. What bothered me was that the compiler, the linker and a quick look at the output sizes had all said yes to a program that couldn’t draw a single frame. Nothing short of actually running it would have caught that.
So I built a bench: a script that opens the game in a real Chromium (headless, inside the Playwright Docker image), presses keys and clicks the mouse, and checks what happens on screen. Most of what I learned from this port came from that bench being wrong.
You can’t read a WebGL canvas
The first thing the bench needs is “did anything draw?”. The obvious answer is to read the canvas pixels. That returns solid black, every time, because WebGL throws the drawing buffer away once the frame is on screen unless you ask it not to.
What works is going the long way round. Take a Playwright screenshot, load it
into an <img>, draw that onto a 2D canvas and call getImageData. Every
measurement in the bench goes through that path.
A test that passes with the keyboard unplugged
The first version of “pressing D moves the player” compared two screenshots of the whole canvas, one before the key and one after, and passed if they were different.
They are always different. The arena has seven enemies that walk and shoot on their own from the first frame. The picture changes between any two moments, with or without a keyboard. The test would have passed with input completely broken, which is worse than not having it, since everything after it trusted it.
So the bench measures the thing that should move. The hero is the only green on screen, so the bench finds the horizontal centre of the green pixels and asserts that D pushes it more than 20 px right and A pulls it back. It has to be measured near the spawn point, too: the camera follows the hero, so after a few steps the hero stays centred and the on-screen movement shrinks to almost nothing.
Jumping was a timing problem. A quick tap of W lifts the hero 21 px and lands in under 90 ms. My first measurement ran 250 ms after releasing the key, saw the hero on the ground and reported a failure that wasn’t one. Holding W for 900 ms reaches 147 px. The bench now takes three samples right after release and keeps the highest point.
exit(0) doesn’t exit
The original game quits on ESC with exit(0). On a desktop that’s fine. On a
web page there is nowhere to quit to, so the port turns ESC into a no-op. The
bench checked that by pressing ESC and confirming the hero was still visible.
That check was useless, and I only found out by breaking the guard on purpose
and rebuilding. With Emscripten’s -sEXIT_RUNTIME=0, exit(0) doesn’t stop
anything. It throws an uncaught ExitStatus exception inside the key handler,
the page logs an error, and the requestAnimationFrame loop that was already
scheduled keeps going. The hero never left the screen, so my check was
testing the one thing the regression doesn’t break.
The assertion that catches the regression counts page errors before and after
ESC. “The hero can still walk after ESC” stays in as a second line of defence,
in case the build ever switches to EXIT_RUNTIME=1.
This became the rule for the whole bench. Every assertion had to be shown to
fail: put the bug back, watch the check go red, then restore it. For the
GL_POLYGON fix that meant editing the patch back to glBegin(GL_POLYGON),
rebuilding, and seeing the bench catch the abort. One check didn’t survive
the rule at all: “holding W jumps higher than tapping” compared against
Math.min of a list that could be empty. Math.min() of nothing is
Infinity, so any height passed.
What the game did to the page
GLUT on Emscripten listens to keydown and keyup on the whole window and
calls preventDefault on Tab, Enter and Space. The game doesn’t use any of
them. Once someone clicked Play, keyboard users were stuck on the canvas. Tab
couldn’t reach the footer or the theme button, or even the “play again”
button on the game’s own end screen. A capturing listener on the window,
registered before the game module loads, now grabs those three keys before
GLUT sees them.
That end screen was broken too. GLUT’s bitmap fonts don’t exist in
Emscripten, so “You won” and “You lost” are HTML now, shown over the canvas. The
C++ calls into the page on every frame while the game is over. When R restarts
the game it just stops calling, and nothing told the page to hide the message.
The overlay stayed on top of a running game and swallowed every click. Now
restartGame() tells the page when play starts again.
The last one only showed up on the real site. The bench served the three build
files from one flat directory. On the site, the page lives at /projects/...
and the game at /demos/.... Emscripten’s loader looks for jogo.data (the
packed SVG arena) relative to the page, not to jogo.js, so on the real site it
was a 404 every time. A locateFile option in the loader fixed it, and the
bench gained a second mode that loads the real built page, because the flat
version can’t see this kind of bug by design.
Three patches, and a lot of bench
The game itself barely changed: three small patches on a pinned commit of the
original repo, and the assignment’s own make still builds the desktop version
with the original GLUT text.
I went in expecting OpenGL to be the hard part. It was the bench.