Joc: putting a hand-written OpenGL engine in the browser
"Joc" is just game in Catalan, which tells you how much thought went into the name. It is a horizontally scrolling shoot-'em-up, clearly modelled on R-Type: you fly a ship through a tile-based industrial level on an on-rails camera, shoot things, pick up a detachable weapon pod, and fight a segmented snake boss at the end.
The part I actually care about is underneath. It does not run on Unity or Godot or libGDX. It runs on Shambhala, a roughly 4,000 line OpenGL 4.3 engine that we wrote ourselves for the course, with its own resource system, component system, material system, deferred-ish render chain and an ImGui level editor bolted on the side. The game on top is another 3,500 lines.
Which is a nice thing to have written and a terrible thing to distribute. For four years the only way to play it was to clone the repo, install the OpenGL and X11 headers, and build it. So this year I compiled the whole thing (engine, game, editor deps and all) to WebAssembly against WebGL2, and now it is just a page.
Play it right here
This is the real C++ engine, compiled with Emscripten, running on your GPU through WebGL2. Click the canvas first so it takes the keyboard, then hit JUGAR.
First load pulls about 5.4 MB (a 0.8 MB .wasm plus a 4.6 MB asset bundle). The menu is in Catalan: JUGAR is play, SORTIR is quit.
Controls:
| Action | Keys |
|---|---|
| Move | W A S D or the arrow keys |
| Fire | Space or J |
| Charge beam | hold K, release at full charge |
| Force pod | L |
| Back / pause | Esc |
| Fullscreen | F, or the button top right |
A few things worth knowing before you die to the first turret:
- The ship has velocity damping, not instant stops. It drifts. This is either good feel or a bug depending on how charitable you are.
- The charge beam needs a full five seconds held, and if you let go early you get nothing at all. The charge is discarded, not partially spent. (It was supposed to tint the hull red while winding up. That tint has never rendered once in the game's entire life, more on that later.)
- Shots inherit half the ship's velocity, so moving while firing angles your fire.
- There are two pickups, an amber one and a blue one. Both give you a three-way spread, and the blue one also parks a pod next to your ship. Once you have the spread, firing just does it, there is no extra key.
- Falling off the left edge kills you, because the camera drags you forward and does not care about your feelings.
What is actually in there
One level, one boss, and four enemy archetypes that are all instances of the same data-driven struct. A grenade guy that lobs shots under gravity, a turret that aims at you, a jumper that hops around, and a flying shooter. Their positions come from four text files, but their classes are hardcoded in main.cpp, which means adding a second level currently means editing C++. That is on the list.
The boss is 30 segments on a scripted path with its own health bar, triggered by your progress along the level (x ≥ 350), which also locks the camera so you cannot run away from it. Kill it and you get the victory screen, which is the win condition and, at time of writing, the entire ending.
Presentation-wise: three parallax layers plus a fog layer plus a floor layer, all under a perspective projection rather than an orthographic one, so the layers have genuine depth instead of just scrolling at different speeds. HDR scene target, bloom, tone mapping, gamma. A bitmap font. All the UI text in Catalan, because it was a Catalan university course and nobody was going to read it anyway.
There is no audio. There are fifteen audio files in the repo (lasers, explosions, two music tracks, in both WAV and OGG) and three vendored audio libraries, and exactly zero lines of code that reference any of them. Four years and nobody noticed. I will come back to those files in a minute because they turned out to matter for a completely different reason.
Getting it into a browser
The plan was: keep the native builds byte-for-byte unaffected, put every change behind __EMSCRIPTEN__ or if(EMSCRIPTEN), and see how far the toolchain carries me before something actually breaks.
Here is the whole mapping:
| Area | Native | WASM |
|---|---|---|
| GL loader | GLEW | none, WebGL2 resolves symbols directly |
| Windowing | vendored GLFW | Emscripten's GLFW3 port (-sUSE_GLFW=3) |
| GL API | OpenGL 4.3 core | OpenGL ES 3.0 (-sFULL_ES3, WebGL2 only) |
| Main loop | blocking do/while | emscripten_set_main_loop |
| Assets | files relative to $PWD | MEMFS via --preload-file |
| MSAA | 4x | 0 (the browser compositor handles it) |
| GI / UV atlas | built | excluded (it was dead code anyway) |
Two of those are structural and the rest are flags.
The main loop has to be inverted. A native game owns the loop: you while (running) { poll; update; render; swap; } and the OS waits for you. In a browser you do not own anything, the browser owns the frame and calls you. So Joc::loop had to be split so that loopStep(frame) does exactly one frame's worth of work, and then handed to emscripten_set_main_loop. That is the single most invasive change in the port and it is about fifteen lines.
Desktop GL has symbols ES does not. glPolygonMode, glPointSize, glDrawBuffer, and tokens like GL_FILL, GL_LINE, GL_MULTISAMPLE, GL_GEOMETRY_SHADER. A 2D sprite renderer does not need a single one of them, so they live in a tiny gl_compat_es.hpp that stubs them out and the rest of the engine never finds out.
Then the shaders happened.
57 shaders, all written in the wrong language
Every shader in the tree opens with #version 330 core and gets fed straight to glShaderSource. WebGL2 consumes GLSL ES 3.00, which is close to desktop 330 but not close enough. Editing 57 files by hand was not appealing, and it would have made the shaders worse on desktop, so instead device::compileShader rewrites each one on the way in, guarded by __EMSCRIPTEN__.
It does four things:
- swaps the
#versionline for#version 300 es - injects
precision highp float/int/sampler2D(ES fragment shaders have no default float precision and must declare one, desktop GL has none of this) - rewrites
texture2D(andtextureCube(totexture(, both were removed in ES 3.00 and 330 kept them as compatibility aliases so plenty of shaders still used them - strips uniform initializers, which are illegal in ES
Here is that transform, running live. Type in the left pane and watch the right one:
#version 300 es
precision highp float;
precision highp int;
precision highp sampler2D;
in vec2 uv;
out vec4 color;
uniform sampler2D input_texture;
uniform vec2 stScale;
uniform vec4 tint;
void main() {
vec4 t = texture(input_texture, uv * stScale);
if (t.a < 0.1) discard;
color = t * tint;
}
addedrewrittenvalue pulled out (re-applied with glUniform after link)
recovered defaults: stScale = vec2(1.0)
Point 4 is the one that bites. The obvious implementation is to delete the = vec2(1.0) and move on. That compiles. It is also silently wrong, because now stScale reads back as zero, every sprite's UVs collapse to a single texel, and you get a screen full of flat colour with no error anywhere.
So the transform parses the initializer instead of throwing it away, remembers the value per shader, and re-applies it with glUniform* after the program links. If it cannot parse one it says so in the log rather than pretending. It is a small thing but it is the difference between "the port works" and "the port works and I have no idea why the tiles are beige".
The two things the transform genuinely cannot fix, so they got fixed in the shader source (in ways that are still valid desktop 330, checked with glslangValidator):
- ES has no implicit int to float.
x + 1,st * 1,16.0f,uint * float. Four shaders. - Multiple fragment outputs need explicit
layout(location=)in ES. This one took outerror.fs, which is the fallback program the engine binds when a real program fails to link. So the first failure caused a link failure in the thing meant to handle failures, and the console filled with "no valid shader program" forever. Very funny in hindsight.
Bug one: the picture was too dark
Once everything compiled, the game rendered, and it looked washed out and flat compared to the desktop build. My first instinct was the tone mapping, since gamma is where colour bugs usually live.
It was not the tone mapping. It was this line in blend2d.fs:
vec3 bright = max(vec3(0.0), scene - 1.0);
That is the bloom extraction: take whatever is brighter than white, blur it, add it back. Standard stuff, and it depends completely on the scene target being able to hold values above 1.0. On the port I had downgraded the scene target from RGBA16F to RGBA8, because float-renderable buffers are optional in WebGL2 and I wanted the boot path to be boring.
On an 8-bit target every value is clamped to 1.0 on write. So scene - 1.0 is identically zero, everywhere, always. The bloom pass was still running. It was still allocating, still blurring, still compositing. It just had nothing to work with, and it never said a word.
Drag the slider and flip the target format:
peak output luminance: 0.00 (bloom pass contributes nothing: every stored value is ≤ 1.0)
The fix is RGBA16F again, gated on EXT_color_buffer_float with the 8-bit path kept as a fallback. Measured against a native reference, peak output went from 0.45 to 0.86 (native is 0.94).
While I was in there I also found device::createTexture setting a mipmap filter mode as the magnification filter, which is invalid and was raising GL_INVALID_ENUM on literally every texture the engine created. It had been doing that on desktop too, for four years, silently. Desktop drivers are very forgiving. WebGL2 tells you.
Bug two: the beams that drew nothing
The pod's diagonal spread beams are not sprites, they are trailing vertex ribbons that bounce off level geometry and fade over about a tenth of a second. Natively they render fine. In the browser: nothing. No error, no warning, no missing texture, no black rectangle. Just nothing.
force.vs turned out to be the only vertex shader in the entire tree that left its attribute locations to the compiler:
in vec2 aPosition; // engine uploads position at index 0 ok
in float type; // engine uploads emissionTime at index 1 wrong
in float emissionTime; // engine uploads colour at index 2 wrong
So emissionTime was receiving the per-shot constant colour, and type was receiving the timestamp. And force.fs derives the beam's brightness and its alpha from (uTime - vEmission) raised to the fourth power. A wrong emission time is not a wrong colour. It is alpha zero.
the fragment shader computes a = pow(1 - (uTime - vEmission) / life, 4), so a wrong vEmission is not a wrong colour, it is alpha 0 and no GL error at all. newest beam alpha right now: 0.000
Desktop GL happened to assign locations in an order that survived the mistake, for four years. WebGL2 is free to choose differently, and did. Nothing in the engine calls glBindAttribLocation, so any shader without explicit layout(location=) was riding on compiler luck. That was the last one.
The part worth writing down is how it was found, because the failure produced no diagnostic of any kind:
- Ran the native build under Xvfb, driven with
xdotool. The beams rendered. So it was a port bug, not a gameplay bug. - Instrumented the render call: 8 live shots, 126 vertices per draw. So the shots existed and the draw was happening.
- Turned on
TRACE_GL='*'(a wrapper that traces every WebGL call). Zero GL errors. The draw was accepted and produced nothing. - Bisected the fragment shader. Replaced the output with a flat colour, and the beams appeared. So the geometry and the uniforms were fine and the fault was in the
tterm. - Probed the varying as a binary pattern, thresholding per channel, because an earlier attempt to just output the raw value was unreadable (the post-process tone mapping was mangling it before I could see it). That finally showed what
uTime - vEmissionactually was.
Five steps to find one line. This is what porting is, mostly.
Bug three: there was no bug three
The one thing I expected to break did not.
The tilemap is baked to a texture once at load, and so is its shadow map, by drawing the tile silhouette 200 times, each pass offset a little further along the light direction and weighted a little weaker, accumulating into a buffer. It is a brute-force directional falloff, and it is exactly the kind of thing I assumed a browser would refuse to do at load time.
It ran in WebGL2 unmodified, first try.
one pass is a hard offset copy. two hundred of them, each a little further along the light direction and a little weaker, add up to a soft gradient. no ray marching, no shadow map lookup, just the same silhouette drawn 200 times into an accumulation buffer and baked once at load.
Drag the pass count down to 1 and you can see what a single pass is: a hard offset copy of the silhouette. Two hundred of them add up to a gradient. No ray marching, no shadow map lookup, no depth comparison, just the same geometry drawn over and over into an accumulation buffer and then never touched again.
The 51 MB problem
Emscripten's --preload-file builds a MEMFS bundle: it walks the directories you point it at and packs everything into a .data file that the loader fetches before main runs. I pointed it at the three asset roots the engine resolves paths against, and got a 51 MB download.
Which is, for a browser game about a small pixel ship, insane. Nobody is waiting for that.
The interesting thing is what was in it. Not textures, not the level. Thirty megabytes of music and sound effects that no line of code references, plus another fifteen of the engine's own sample and debug art: a canyon texture set, a checker pattern, an obj of a donut, a full PBR test scene. Content that exists because Shambhala was a general-purpose engine before it was this game's engine, and it all got swept in because --preload-file takes a directory, not a manifest.
So the fix is a list of --exclude-file patterns next to the preload flags:
"SHELL:--exclude-file */joc2d/music/*"
"SHELL:--exclude-file */joc2d/sound/*"
"SHELL:--exclude-file */assets/objects/*"
"SHELL:--exclude-file */assets/modules/*"
"SHELL:--exclude-file */assets/textures/canyon/*"
"SHELL:--exclude-file */assets/textures/3d/*"
"SHELL:--exclude-file */assets/textures/checker.jpg"
51 MB to 4.6 MB. The patterns are fnmatch'd against the full path, hence the leading *.
I want to be honest that this is not clever engineering, it is deleting things. But it is worth noticing that a native build never punished any of this. The files sat on disk, the engine opened the handful it needed, and the other 46 MB cost exactly nothing. Shipping to a browser is the first time anyone had to pay for what was in the tree, and it turns out the tree was mostly landfill.
The proper version of this is a manifest: enumerate the assets the game actually opens (there are about 60) and preload only those. That is a build-time job and it would also catch new dead assets as they appear. The exclude list is the version I have.
Shipping it
The build spits out four files that all have to sit in the same directory and be served over HTTP (opening the .html from file:// fails, the browser blocks the .wasm and .data fetches):
| File | Size | What |
|---|---|---|
joc_wasm.html | 3.5 KB | the page shell: canvas, letterbox layout, fullscreen, log panel |
joc_wasm.js | 156 KB | Emscripten runtime and loader |
joc_wasm.wasm | 819 KB | the compiled engine and game |
joc_wasm.data | 4.6 MB | the MEMFS asset bundle |
Getting that into this blog is an npm script and a copy step. The copy renames the shell to index.html so the game lives at /games/joc/ and embeds with a plain iframe:
"game:joc:build": "PATH=/usr/lib/emscripten:$PATH research/VJ/scripts/build_wasm.sh",
"game:joc:copy": "node scripts/copy-joc-wasm.mjs",
"game:joc": "npm run game:joc:build && npm run game:joc:copy"
The copy script is thirty lines of node:fs with no dependencies. The one rule that matters: put the output in public/ (or static/, depending on your framework) and do not put it under src/ and import it. The loader resolves joc_wasm.wasm and joc_wasm.data relative to its own URL at runtime, so a bundler that hashes and moves those files quietly breaks the lookup.
Three deployment things that took me longer to learn than they should have:
.wasmmust be served asapplication/wasm, orWebAssembly.instantiateStreamingrefuses the response and you fall back to a slower path (or fail outright). Managed hosts get this right. Self-hosted nginx needstypes { application/wasm wasm; }if itsmime.typesis old.- Turn on compression for the payload. The
.wasmand.dataare the whole download and they compress well. - You do not need COOP/COEP. The build uses no threads and no
SharedArrayBuffer, so cross-origin isolation buys nothing here, and adding it speculatively breaks third-party embeds and fonts. Every "getting started with WASM" post tells you to add those headers. Check whether you actually need them.
The other one: emscripten emits fixed filenames, so a blanket immutable cache policy pins players to a stale build forever. Either version the directory or cache the payload hard and revalidate the 3.5 KB entry point every load.
Testing a thing you cannot see
The browser console is the only diagnostic channel a WASM build has, and the engine's LOG macro compiles to nothing unless DEBUG is defined, which was fun to rediscover. There is now a -DJOC_WASM_DEBUG_LOG=ON for exactly that.
For actually checking it runs there is a headless smoke test: launch the build in headless Chromium with SwiftShader (a real WebGL2 context, no GPU needed), collect the engine's console output, optionally click a point on the canvas and hold a key, then write screenshots and a pixel histogram.
./scripts/wasm_smoketest.sh --seconds 6 --click 0.5,0.35 --after 8 --hold w
Two things it has to work around, both of which cost me an afternoon:
- A WebGL canvas is cleared once composited, so every readback comes back black unless you force
preserveDrawingBuffer. The render was fine, the screenshot was lying. - The pixel histogram exists so a screenshot that is black for page layout reasons can be told apart from a black render. Those look identical in a PNG and are completely different bugs.
The --click argument is not a nicety either. The game boots into a mouse-driven menu, so without it a headless run only ever proves the menu renders.
What it still does not have
I said the audio files turned out to matter, and they did, just not the way you would want: they were 60% of the download for a game with no sound. That is the whole story of this port in one detail. The gap between what was in the repo and what the program actually did had never cost anything, so nobody ever measured it.
The rest of the list, honestly:
- Audio. Fifteen files, three libraries, zero code. It is the single biggest gap between the content and the game.
- Score. No points, no combo, no table.
- Lives. One health bar, death goes straight to retry.
- One level, and adding a second means editing
main.cpp, because enemy classes, spawn tables, parallax layers and the boss trigger are all code rather than data. - Touch controls. The browser build is unplayable on a phone, which is a slightly embarrassing thing to say about a browser build.
- The charge-up hull tint, which sets a
tintuniform that the ship's shader does not declare. It has been a no-op since 2022. This is why the spread pickup is signalled by its amber beams and not by the hull, a design decision that was actually a bug the whole time.
What I got out of it
The port itself was maybe two weeks of evenings, and most of that was the five-step debugging session for one missing layout(location=).
What I did not expect is how much it told me about the desktop build. Three of the bugs above were live on desktop the entire time: the invalid magnification filter, the attribute order, the dead tint uniform. They never surfaced because desktop drivers are permissive, memory is cheap, and a 51 MB asset folder on an SSD is not a number anyone looks at.
WebGL2 is the same API with the forgiveness removed. It will not accept an invalid enum, it will not guarantee your attribute order, it will not give you a float render target unless you ask and check, and it makes you pay for every byte you ship. Porting to it was less "make it work in a browser" and more a very thorough, very tedious code review with a browser doing the reviewing.
Anyway, it is a page now. Go fight the snake.
