The web build
The web build runs the game in a web browser, as WebAssembly. It is the
same game and platform layer as the Linux build, with the OpenGL ES renderer
of the Android build drawing through WebGL 2. Play it at
https://halocombatevolved.com/: every build of main that passes on all
platforms is published there (and as halo-web-release.zip in the release,
for your own server).
The site serves no game data. The page copies the maps/ folder out of your
own Xbox disc image of Halo: Combat Evolved into the browser's storage for
the site. Nothing is uploaded.
What it needs
- A desktop browser: Chrome or Edge, or Firefox. Safari 17 or later may work. The page checks what it needs and says what is missing.
- A 64-bit browser that gives the page 2.1 GB of memory (the Xbox's memory window is at its top: "How it operates"). Desktop browsers do; a phone's may not (the page says so), and iPhones are untried.
- About 3 GB of the browser's storage: the maps (about 2 GB), and the
game's copies of the maps it played (up to about 800 MB, as on the Xbox's
hard disk), and the disc image (
.isoor.xiso) on the computer. - The site over https (or
localhost): the game's threads needSharedArrayBuffer, and so a cross-origin isolated page. GitHub Pages cannot send the headers for that; the site's service worker adds them, so the page loads once more on the first visit.
Game data
- Open the site. The This browser panel lists the checks.
- Under Game data, choose Choose a disc image… and select the disc
image. The page reads it in place and copies
maps/into the browser's storage (about a minute for 2 GB). Choose a maps folder… takes amaps/folder that an earlier extraction made (a desktop build's). - Choose Play.
Every map is checked as the game checks it before it is copied (its head
and foot signatures, the Xbox cache version and the build of a retail disc,
01.10.12.2276, or of the decompilation, 01.01.14.2342). A disc image is
untrusted input: the reader checks every offset and size in it.
Mods (docs/mods.md): under Mods, Add a mod folder… copies a mod's
mod.toml and tags folder into the browser's storage (mods/, which the
game reads as a native build reads the data root's), replacing a mod of the
same folder's name, and Remove deletes one. The folder's paths are
checked as it is copied (mods-worker.js). Every mod is on; the game's main
menu, Mods, orders them and turns them off. The game asks in a dialog of the
page when mods say they cannot be played together (web_js_ask).
The page asks the browser to keep the site's data. A browser that is short
of space may delete it otherwise; Settings and data says which. The
storage belongs to the site's address: halocombatevolved.com and the
repository's github.io address do not share it.
Playing
The controls are the desktop builds' (refer to "Controls" in port/linux/README.md), with these differences:
- Crouch is C only: a page cannot keep Ctrl+W (close the tab), Ctrl+S or Ctrl+D from the browser.
- A click on the game takes the mouse for aiming; Escape gives it back (the browser's rule). In the menus the mouse moves the menus' pointer.
- Tab stays with the game (the scoreboard, switching weapons).
- The Fullscreen button (top right) takes the page fullscreen. F11 is the browser's own.
- A controller works once one of its buttons is pressed: browsers show a page a controller only then.
- The sound starts at the first click or key press (browsers allow sound only then).
- On a touchscreen, the Android app's on-screen controls (a stick, the
controller's buttons named by the profile's mapping, a swipe to turn) show
in a game while no controller is connected (
input.touch_controls:auto,on,off); the menus take taps. The phone vibrates with the controller's rumble where the browser lets it. There is no layout editor nor gyroscope aiming yet.
The game keeps config.toml, the saved games and profiles (save/) and
debug.txt in the browser's storage beside maps/. Settings and data
exports the saved games, the profiles and config.toml as a zip (not the
game's copies of the maps), downloads the log, and deletes the game
data. The game's own Settings screens have the settings that apply in a
browser.
Playing together
Over the internet, as the Linux, Windows and Android builds play, and
with them: Multiplayer, Create Game > Internet hosts a game (PUBLIC lists it
in everyone's server browser), and Join Game > Server Browser lists the
public games, the native builds' and the browsers' alike. A game hosted here
shows its invite link at the top of the page, with a button that copies it:
the site's address with ?join=<code>, which opens the site and joins the
game, and which a native build takes too (pasted, or on its command line);
the native builds' own halo://join/ links work pasted into the address
after ?join=. Network Setup's INTERNET PLAY turns it off.
The browser reaches each other machine of the game directly, over WebRTC (its router permitting, as for the native builds: "Internet play" in port/linux/README.md); a native build takes the browser's WebRTC on its tunnel's port (port/linux/src/p2p_webrtc.c). They find each other through the same public MQTT brokers, here over secure WebSockets (EMQX's, HiveMQ's and Mosquitto's: the native builds' first broker has none), with everything sealed as between native builds.
In a room: under Play together, Create a room makes a room and
its link (https://halocombatevolved.com/?room=<secret>): send it to your
friends.
Everyone who opens it is in the room, and the browsers of a room are as the
machines of one local network to the game: host a game under Multiplayer and
the others find it there, as system link games are found (up to 16
browsers). The link's secret is the room: anyone with it can come in.
The browsers connect to each other directly (WebRTC, through each one's router with the help of the STUN servers internet play uses). To find each other they post to a topic of the room on public MQTT brokers (EMQX's, HiveMQ's and Mosquitto's, over secure WebSockets): the topic is named by a hash of the secret and every message is sealed with a key derived from it, so the brokers see nothing of the room. A network that lets no WebRTC through (some company and school networks) keeps a browser out of rooms.
Address parameters
For testing, the page's address takes:
| Parameter | Effect |
|---|---|
?room=<secret> |
Joins that room (32 hex digits); ?brokers=ws://host:port[,...] uses other MQTT brokers (WebSocket listeners), for rooms and internet play, as the tests do. |
?join=<code> |
Joins that internet game once the game starts (an invite's 64 hex digits, as a halo://join/ link ends). |
?set=NAME=value |
Sets a setting's environment variable (the settings table in port/linux/README.md); repeat it for more. ?set=HALO_GL_DEBUG=1 reports WebGL errors in the log. |
?menu=<screen> |
Opens a menu screen (debug.menu_open). |
?map=<level> |
Starts a level: a10, or levels\test\bloodgulch\bloodgulch. The page writes init.txt for it, and removes it at the next visit without ?map=. |
?play |
Starts the game at once (when the game data is there). |
Updates
version.json names each build (a hash of the site's files, the build
number, the commit and the network version). The service worker keeps the
files of one build together, so that the site starts without the network.
When a new build is out, the page says so; Update downloads it whole and
reloads.
Limits
- No system link with the native builds on a local network (a browser has no UDP): they play over the internet, as invites and the server browser find them ("Playing together"). Split screen works.
- Internet play has no relay, as on the native builds: two machines whose networks let no direct connection through (some company and school networks block WebRTC) cannot play together.
- No Custom Edition maps (their tag data's address is in the C heap there),
so a browser neither downloads nor sends one (map sharing,
port/linux/NETCODE.md). - No Bink videos (as on the other ports).
- The browser's WebGL 2 is OpenGL ES 3.0 without its 3.1 and 3.2 additions: the visibility tests (lens flares) report whether anything was drawn, a frame late; supersampling and SMAA are not offered (FXAA and MSAA are).
- Performance depends on the browser's graphics: Chrome and Edge are the fastest. A software renderer (no graphics acceleration) is very slow.
Building
Install Emscripten 6.0.10 with emsdk:
git clone https://github.com/emscripten-core/emsdk.git ~/emsdk
~/emsdk/emsdk install 6.0.10
~/emsdk/emsdk activate 6.0.10
source ~/emsdk/emsdk_env.sh
Then, in the repository:
python configure.py # finds emcc on the PATH, else ~/emsdk's (--web-emcc PATH)
ninja web # build/web/site/
python tools/web_serve.py # http://localhost:8000/
The first build fetches SDL 3.4.16's source and builds it as an Emscripten
port (port/web/halo_sdl3.py, its hash checked), once. configure.py --release makes the release build (no assertions checked); the debug build
stops at the first failed assertion, which the page shows with the log.
tools/web_serve.py serves the site with the isolation headers, byte ranges
and no caching; --as-github-pages leaves the headers out, so that the
service worker isolates the page as on GitHub Pages; a folder argument
serves another site (a halo-web-*.zip from a release, say).
python tools/ci_build.py web release makes the site in
dist/halo-web-release/, as CI does.
Tests
node --test "port/web/tests/*.test.js" # the disc image reader, the service worker
python -m pytest -q tools/test_web_build.py # the build's graph, version.json, the sockets, the menus
node port/web/tests/smoke.mjs dist/halo-web-release # the site in a headless Chromium (Playwright)
node port/web/tests/rooms.mjs dist/halo-web-release # two browsers in a room (and mosquitto)
node port/web/tests/webrtc_native.mjs chromium firefox # the native builds' WebRTC with browsers
node port/web/tests/internet.mjs build/web/site <maps folder> # internet play with the Linux build
The smoke test serves the site as GitHub Pages does, checks that the page
isolates itself and passes its checks, and starts the game with an empty
maps folder, which gets as far as looking for the menus' map. The rooms test
starts a local broker and two browsers that join one room and connect. A
game between two browsers needs the maps: two pages of the same build with
the multiplayer maps imported, one opened with
?room=<secret>&set=HALO_NETWORK_TEST=host:bloodgulch and the other with
?room=<secret>&set=HALO_NETWORK_TEST=join (debug.network_test,
port/linux/NETCODE.md), play one and log every player's state each second.
The WebRTC test builds port/linux/src/p2p_webrtc.c (with posix_dtls.c
and Mbed TLS) for this computer around one connection, which a browser's
data channel opens as signalling would have it; the browser sends it
messages of every size, which come back, then again with a tenth of the
datagrams lost each way. The internet play test needs the maps (ui.map
and the multiplayer maps, which each browser imports) and the Linux build
(build/linux/halo), and plays three games through a local broker: two
browsers by invite, the native build hosting with a browser joining from the
server browser, and the reverse (debug.network_test browse,
debug.network_test_public).
Playwright's screenshots of the page in headless Firefox show the game's canvas black (it is drawn by a worker); the game draws. Check Firefox headed, on a virtual display.
How it operates
The game is compiled for wasm32, an ILP32 target as the Xbox was (32-bit
int, long and pointers), with the ABI the other builds reproduce
(16-bit wchar_t, MSVC's extensions, common symbols, no fused
multiply-adds). tools/web_build.py writes the graph.
- Code paths.
HALO_WEBandHALO_GLES: the OpenGL ES renderer, and what a browser does differently. NotHALO_ANDROID, which is the Android app (its touch overlay, its window, its files): in a browser, the menus, the mouse and the keyboard are the desktop's. - Memory. The game expects the Xbox's memory window at
0x80000000(port/linux/src/platform.h), 128 MB as on Android. The WebAssembly memory is made0x88000000bytes, so that the window is its top 128 MB;port/web/src/web_main.ckeeps the C heap below it.xbox_memory.cclears a block where it would map fresh pages, and only remembers protection. - Threads. Emscripten's pthreads, with
PROXY_TO_PTHREAD: the game'smainruns on a Web Worker, where it may block; the page's own thread only passes events. The game's canvas is moved to that worker (OFFSCREENCANVASES_TO_PTHREAD). - Frames. A worker's canvas shows what was drawn when the worker returns
to its event loop, so
main.cruns one iteration of the game's loop for each of the browser's frames (emscripten_set_main_loop) instead of its ownwhile. A halt stops the game (abort); the page shows the log. - Entry.
web_main.cis the program'smain: it turns the page's--NAME=valuearguments into environment variables, mounts the site's Origin Private File System at/data(WasmFS), and starts the game (shell_xbox.c'smain, renamedhalo_game_main). - Calls. WebAssembly calls a function only as its own type, where x86
shrugged. The link fails on a direct call that does not match
(
-Wl,--fatal-warnings); the declarations were corrected. There is no link-time optimisation: LLVM's would turn calls whose parameter types differ only in width, which WebAssembly passes alike, into traps. - WebGL 2. What the Android renderer needed of ES 3.0 the browser has;
the rest:
- No texture swizzle: textures are put in RGBA order as they are
converted (
xbox_textures.c). - S3TC only for 2D textures whose sides are multiples of 4: the others are decoded.
- A vertex stride of at most 255 bytes: an immediate-mode draw uploads
only the registers written since
Begin(as every build does), and one that writes all sixteen (256 bytes a vertex) is drawn from an array for each attribute (d3d8_gl.c). - No feedback loops: a draw that samples the target it draws into samples a copy of it.
- No blend of a constant color factor with a constant alpha one (the
meters, as the ammo counters on the weapons): the source's factor is
applied by the pixel shader instead (
nv2a_pixel_shader_key'ssource_blend). - No
glCopyImageSubData,glDrawElementsBaseVertexorglMemoryBarrier: the ES renderer's blits and rebased indices. An index array drawn twice the same is kept rebased in a buffer of its own and drawn from there, not uploaded at each draw (d3d8_gl.c's index cache). - Each call into WebGL costs a crossing from WebAssembly into the
browser, so draws whose streams all come from the mirror bind a vertex
array made for those places once (
d3d8_gl.c's vertex array cache), rather than pointing each attribute again. The renderer draws only from buffers, so Emscripten's emulation of client-side arrays (FULL_ES3), which wrapped every draw, is not linked. - Buffer writes are copies the browser orders with the draws, but WebGL
on Metal (ANGLE, in Chrome and Safari on a Mac) first waits for the GPU
to finish what was queued before when a queued draw reads the buffer.
So each draw's vertices and indices go into a buffer that no queued
draw reads: one of a pool for each frame of the ring, in sizes of 4 KB
to 32 MB, written again only when that frame comes round again
(
web_chunk_getind3d8_gl.c). Written after each draw into one buffer, as before, the frames waited for the GPU some 2000 times each.
- No texture swizzle: textures are put in RGBA order as they are
converted (
- Memory watch. WebAssembly cannot protect pages, so the textures'
memory is watched by hashing it once a frame, as the Android host does
under the x86 emulator (
port/web/src/web_memory_watch.c,port/android/host/host_watch_hash.c). A page that has stayed the same for 30 checks is hashed every 60 frames, until the game locks it or a file is read into it (no other write into such a page was seen in a match). - Sockets.
port/web/src/web_net.cgivesposix.h's sockets inside the page: one machine with a loopback address and an address of its own network (10.0.0.1, or the room's); datagrams to either, or broadcast, reach the sockets bound to their port, and streams connect to the socket listening on theirs. Split screen (a network game over loopback) and the game's own start-up use them. - Internet play. The native builds' (port/linux/src/p2p*.c), all of it:
the invites, the signalling and its sealing, the server browser, the
tunnel's sealed packets and KCP streams, the stand-ins the game's sockets
reach peers through. Two things differ. A broker is a WebSocket URL, which
posix_resolve_ipv4gives a stand-in address (198.19.0.x) and a stream socket connected to it is the page's WebSocket (web_library.js), the same MQTT bytes. And the tunnel to each peer is a WebRTC data channel (port/web/src/web_p2p.c,site/p2p.js): the page keeps a connection ready, whose ICE credentials, addresses (IPv4, and its own as mDNS names) and certificate's hash a request or an answer carries, and makes up the other end's description from what signalling told: a native build's is an ICE-lite agent and a DTLS server, whose credentials come from the session's secret; of two browsers, the host is the DTLS server. What the channel receives goes to the tunnel's socket from the connection's stand-in address (198.18.x.y). - Rooms. The other browsers of a room have addresses of their own in
10.0.0.0/8, whichnet.jschooses (and the game is started with:HALO_WEB_ADDRESS). What the game sends to one, or broadcasts, is a framenet.jscarries over WebRTC: a datagram on a data channel that may lose it (unordered, not resent), a stream's opening, bytes and end on one that does not. What arrives,web_net_receivechecks and hands to the sockets. System link and the netcode run unchanged on top, so the network version needs no change for it. - Left out. The self-updater (the site updates itself), the disc image
reader (the page's
xiso-worker.jsis), STUN, UPnP and Discord (a page reaches none: its WebRTC finds its own addresses), and the page-fault memory watch. - The page and the game. The memory is shared, so the page reads and
writes a few words of it itself (
port/web/src/web_shared.h): the frames shown, whose count standing still while a map loads makes the page say so, and the touch controls' state, whichweb_touch.chands the game as the Android host does (touch_input.c). - SDL. SDL 3.4.16, the release the other builds use, built as an Emscripten port: windows, input, controllers and audio (an AudioWorklet). Its buffer is SDL's own size, not the desktop's 512 frames, which underran in browsers.
Game source changes
Each is marked port: and guarded by HALO_WEB but where it corrects a
declaration:
main/main.c: one iteration of the main loop per browser frame; a halt stops the game.cache/cache_files_windows.c: the cache thread starts at a function ofCreateThread's type.cseries/errors.c:debug.txt's lines go to the page's log too.rasterizer/xbox/rasterizer_xbox_text.c,rasterizer_xbox_motion_sensor.c,rasterizer_xbox_plasma_energy.c,networking/network_game_manager.c,objects/object_types.c: declarations given their definitions' types.
Hosting
.github/workflows/build.yml builds the site in the web job, puts both
builds in the release (halo-web-release.zip, halo-web-debug.zip), and
the pages job deploys the release build to GitHub Pages: only from main,
only once every platform built and the release is out, and never cancelled
halfway. The Discord post has a Web field and the site's address.
The game's wasm, most of a build, does not come from GitHub Pages, which
serves 100 GB a month: the pages job pushes it alone in a commit of the
web-wasm branch (over the last one, so the repository keeps one copy),
checks that jsDelivr serves that commit's file
(https://cdn.jsdelivr.net/gh/OpenCommunityEdition/OpenCE@<commit>/halo.wasm,
with CORS, which GitHub's release downloads lack), and names it and its
SHA-256 in version.json ("wasm": {"url", "sha256"}). The service worker
fetches the wasm from there as it caches the build, checks its SHA-256, and
takes the site's copy only when that fails; the page loads halo.wasm from
the site as before, from the worker's cache. jsDelivr serves files of at most
20 MB from GitHub: a larger wasm comes from the site, and the step says so.
The page counts its visits with Umami (https://nexus.mrbruh.com/app-state
in index.html, which sets no cookies). The page being cross-origin
isolated, the script is loaded with crossorigin="anonymous": its host sends
CORS headers, not Cross-Origin-Resource-Policy.
Once, an owner of the organization sets it up:
- Organization settings → Pages → Add a verified domain:
halocombatevolved.com. Add the TXT record GitHub shows (_github-pages-challenge-OpenCommunityEdition.halocombatevolved.com) at the DNS host (Cloudflare) and verify. This also frees the domain from another account's site that still claims it. - Repository settings → Pages → Build and deployment → Source: GitHub Actions.
- Repository settings → Pages → Custom domain:
halocombatevolved.com. The DNS records are there already (the four apexArecords of GitHub Pages,wwwaCNAMEto the apex), DNS only at Cloudflare, so that GitHub can issue its certificate; then tick Enforce HTTPS. - Nothing else: the deployment needs no secret, and the
github-pagesenvironment appears with the first one (give it no required reviewer).
Later, Cloudflare can serve the site (proxied, SSL Full (strict)) with a
response header rule adding Cross-Origin-Opener-Policy: same-origin,
Cross-Origin-Embedder-Policy: require-corp and
Cross-Origin-Resource-Policy: same-origin: the first visit then needs no
reload, and Cloudflare's cache takes GitHub Pages' bandwidth (100 GB a month;
a first visit downloads about 10 MB, compressed, most of it the wasm from
jsDelivr).
Files
| File | What it is |
|---|---|
tools/web_build.py |
The ninja web graph |
port/web/halo_sdl3.py |
SDL 3.4.16 as an Emscripten port |
port/web/src/web_main.c |
The entry point: the page's arguments, the storage, the heap's limit |
port/web/src/web_net.c |
The sockets inside the page, the room's other browsers, and the brokers' WebSockets |
port/web/src/web_p2p.c |
Internet play's WebRTC connections |
port/web/src/web_memory_watch.c |
The memory watch by hashing |
port/web/src/web_gl_host.c |
The renderer's services for WebGL 2 |
port/web/src/web_stubs.c |
UPnP and an MSVC intrinsic |
port/web/src/web_shared.h, web_touch.c |
The words of memory the page reads and writes: frames shown, the touch controls' state |
port/web/src/web_library.js, web_pre.js |
The JavaScript halves: messages to the page, the brokers' WebSockets, the workers' errors |
port/web/site/ |
The page (index.html, app.js, style.css), the touch controls (touch.js), rooms (net.js), internet play's WebRTC (p2p.js), its service worker (sw.js), the importers (xiso-worker.js, the maps; mods-worker.js, the mods), the web app's manifest and icons |
port/web/stamp_version.py, licenses.py |
version.json and licenses.txt |
port/web/tests/ |
The tests above |
tools/web_serve.py |
A local server for the site |