Indie Machine logoINDIE / MACHINE
BACK TO DEV POSTS
FIG. 01ALLEGIANCE SERIES

Porting Allegiance to Babylon.js: A Full-Scale Earth MMO in WebGL 2

DATE
2026-10-15
SERIES
Allegiance
View on GitHub
alexthegoodman/allegiance
BUILD SPEC
ENGINE
  • @babylonjs/core = "9.30.0"
  • meshoptimizer = "1.3.0" (WASM)
  • gl-matrix = "3.4.4"
TOOLING
  • vite = "8.3.4"
  • typescript = "5.9.3"
  • vitest = "4.0.16"
  • playwright = "1.64.1"
NETCODE
  • next = "16.3.4" (Indie Machine API)
  • prisma = "7.10.0" (@prisma/adapter-pg)
  • wrangler = "4.145.0" (Cloudflare Workers + Durable Objects)
TARGET
ES2022, WebGL 2.0, Web Workers, IndexedDB
OS
Windows 11 Pro 10.0.26200
HARDWARE
Intel Core i5-12500, Intel UHD Graphics 770, 32 GB RAM

Allegiance began as an internal experiment inside Entropy: a political conquest game set on a full-scale Earth in 2100. You start in a real hometown with five armed comrades and an objective to take a regime outpost. From there, you print pamphlets, give speeches on street corners, recruit citizens, organize a party, and take over the planet region by region through elections, coups, or war.

In Entropy, the game was a native desktop application built with Rust and wgpu, running TypeScript game logic in Deno. It looked good and ran well, but keeping a political conquest game trapped in a local native binary was an architectural mismatch. A game about competing factions, shifting borders, and global propaganda belongs on the open web, where any player can open a link, found a nation, meet rivals on the street, and fight over real territory.

This week we completed the port of Allegiance to Babylon.js in its own standalone repository (D:/projects/common/allegiance), running in WebGL 2 without React. On top of the ported client, we layered an online-only MMO infrastructure using the Indie Machine API for persistent territory, anonymous sessions, and accounts, backed by Cloudflare Durable Objects for low-latency spatial rooms and refereed combat.

Here is how the port was structured, what changed when moving from Rust/wgpu to TypeScript/WebGL 2, the shader compilation trap that froze Chrome for 15 seconds, and how the networked simulation operates.

A street on London's South Bank rendered in Babylon.js WebGL 2
A street on London's South Bank rendered in Babylon.js WebGL 2

What changed in the port

cd allegiance
npm install
npm run typecheck
npm run test:unit
npm run build
ARCHITECTURE DIAGRAM
Rendering diagram...

1. The coordinate system: planetary scales without 32-bit jitter

Earth has a mean radius of 6,371,007 metres. Standard 32-bit IEEE 754 floating point numbers have 24 bits of mantissa, giving roughly 7 decimal digits of precision. If an engine attempts to place a coffee mug or a rifle barrel at absolute Earth coordinates [x, y, z], the distance between representable float values is:

6,371,007 * 2^-24 ≈ 0.38 metres

Placing objects in world coordinates would cause every vertex on screen to wobble by almost 40 centimetres every time the camera turned.

To solve this, Allegiance retains QuadPlanet's render-space paradigm. The scene is drawn relative to a floating renderOrigin situated directly on the planet surface beneath or near the camera:

// src/engine/terrain.ts
private record(p: Placed): void {
    const t: V3 = [p.origin[0] - this.origin[0], p.origin[1] - this.origin[1], p.origin[2] - this.origin[2]];
    const m = p.basis ? tileMatrix(p.origin, p.basis, this.origin) : [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, t[0], t[1], t[2], 1];
    p.drawable.setItem(m, [1, 1, 1, 0], p.basis ? [0, 0, 0, 0] : [p.texOrigin[0], p.texOrigin[1], p.texOrigin[2], 0]);
    p.drawable.setBounds(t, p.radius);
    if (p.drawable.instanceCount !== 1) p.drawable.setCount(1);
}

When the player moves across the city, the game checks if the distance from renderOrigin exceeds a threshold. When it rebases, the vertex buffers in GPU memory remain completely untouched. The engine simply updates the 16-float model matrix in each instance record:

apply(update: EarthUpdate, renderOrigin: V3): void {
    const moved = renderOrigin.some((v, i) => v !== this.origin[i]);
    this.origin = renderOrigin;
    if (moved) for (const p of [...this.chunks.values(), ...this.tiles.values()]) this.record(p);
    // ...
}

Because coordinates inside each mesh are local offsets relative to their chunk or tile origin, full 32-bit precision is preserved where the player stands.

Custom planetary camera

In standard game engines, the camera's up vector is fixed to [0, 1, 0]. On a spherical planet, "up" is whichever normal vector points directly out from the planet's core at your current latitude and longitude.

We extended Babylon's base Camera class to bypass internal orbital transformations and write the view matrix directly via Matrix.LookAtRHToRef:

// src/engine/renderer.ts
export class WorldCamera extends Camera {
    readonly view = Matrix.Identity();
    override getViewMatrix(): Matrix { return this.view; }
}
 
// Positioning the camera in render space:
setCamera(position: readonly number[], target: readonly number[], up: readonly number[], fovDegrees = 0): void {
    Matrix.LookAtRHToRef(
        new Vector3(position[0], position[1], position[2]),
        new Vector3(target[0], target[1], target[2]),
        new Vector3(up[0], up[1], up[2]),
        this.camera.view
    );
    this.camera.position.set(position[0], position[1], position[2]);
    this.eye = [position[0], position[1], position[2]];
    this.camera.fov = (fovDegrees > 0 ? fovDegrees : 45) * Math.PI / 180;
}

Logarithmic depth

A flight in Allegiance can take the player from a street corner inspection of a rifle (view distance 0.1 m) to flying in the Cobra above the troposphere looking at the horizon (view distance 100,000 km).

Standard linear depth precision fails catastrophically across this range. In our GLSL ES 3.00 world shader, depth is calculated logarithmically in the vertex stage (and optionally fragment stage):

// src/engine/shaders/world_glsl.ts
const float LOG_DEPTH_FAR = 1.0e8;
float log_depth(float w) {
    return clamp(log2(max(w, 1.0e-6) + 1.0) / log2(LOG_DEPTH_FAR + 1.0), 0.0, 1.0);
}

Because OpenGL clip space expects z/w in the range [-1, 1], whereas WebGPU clip space uses [0, 1], the vertex shader remaps the logarithmic depth into OpenGL clip coordinates:

float depth = log_depth(gl_Position.w);
gl_Position.z = (2.0 * depth - 1.0) * gl_Position.w;

This ensures identical depth distribution across the entire planetary scale without z-fighting.

2. Terrain and city streaming in an isolated worker

In Entropy, QuadPlanet's chunk meshing and vector tile decoding ran on native background threads using Rust's quadplanet crate. For the browser port, this entire subsystem was rewritten in clean TypeScript within src/quadplanet/earth/.

The streaming architecture runs inside a dedicated Web Worker (earth_worker.ts):

  1. Elevation pyramid. The worker fetches and caches AWS Terrarium elevation tiles (PNG format, where elevation in metres is (r * 256 + g + b / 256) - 32768). Zoom level 0 (terrarium_z0.png) is bundled locally to allow immediate startup.
  2. Cube-sphere quadtree. The planet is divided into six root cube faces, subdivided hierarchically based on the camera position and distance metrics. Seams between differing quadtree LOD levels are stitched by generating transition skirt vertices.
  3. OpenStreetMap vector tiles. City tiles fetch Mapbox Vector Tile (MVT) protobufs from OpenMapTiles. The worker parses building polygons, calculates building heights based on storey tags, generates extruded 3D building box meshes, and rasterizes road ribbons.
  4. Main thread synchronization. The worker posts structured chunks and tiles to the main thread via transferrable typed arrays.

On the main thread, EarthSystem throttles GPU uploads to a fixed per-frame budget, ensuring smooth 60 FPS frame times during high-speed vehicle flight:

// src/quadplanet/earth/system.ts
export class EarthSystem {
    // Ingests worker messages without blocking the render loop
    step(viewer: V3, unlimited = false): EarthUpdate {
        // Collects completed chunks and tiles from the inbox
        // Bounded GPU mesh uploads prevent frame spikes
        // ...
    }
}

3. High-poly procedural assets and WASM mesh simplification

Allegiance does not use static imported FBX or glTF models. Every vehicle, weapon, house, and civilian is generated procedurally by our parametric engine, Mesha (src/mesha/).

Mesha produces high-detail, watertight geometry:

Drawing thirty civilians on a street corner with 400,000 triangles each would melt any GPU. We needed multi-tier level of detail (LOD) meshes generated dynamically and cached.

In Entropy, this was handled by mesh_simplify.rs. For Babylon.js, we ported the pipeline to TypeScript in src/engine/mesh_simplify.ts utilizing meshoptimizer compiled to WebAssembly:

// src/engine/mesh_simplify.ts
export function simplifyPacked(
    vertices: Float32Array,
    indices: Uint32Array,
    params: SimplifyParams
): { vertexData: Float32Array; indexData: Uint32Array; stats: SimplifyStats } {
    // 1. Group triangles by surface (material ID in uv.x + vertex color)
    // 2. Weld vertices by position to 0.1 mm precision
    // 3. Drop disconnected parts whose bounding box diagonal is under minFeature (union-find)
    // 4. Run MeshoptSimplifier to an absolute error in metres
    // 5. Re-emit flat-shaded packed vertices (12 floats per vertex)
}

The union-find step drops tiny components (like individual door handles or window mullions) before running edge collapses, preventing degenerate geometric spikes.

Here are the verified triangle reduction numbers produced by our unit test run (tests/unit/allegiance_people.test.ts):

ModelFull Quality (LOD 0)Reduced (LOD 1)Distant (LOD 2)Reduction
Mesha Male417,46164,9305,35698.7%
Mesha Female407,93385,4306,83298.3%

MeshCache on IndexedDB

Generating a 400k-triangle human and simplifying it takes several seconds of CPU time. To prevent repeated generation, MeshCache (src/engine/mesh_cache.ts) stores compiled geometry into the browser's IndexedDB (allegiance-mesh-cache).

On startup, the cache reads its index into memory in a single transaction. When the game requests a mesh:

  1. If in RAM: returned immediately ("ready").
  2. If in IndexedDB: scheduled for async retrieval ("pending").
  3. If missing: generated in the background by WorkerPool (mesh_worker.ts).

An in-memory LRU budget (default 1.2 GB) prevents V8 heap exhaustion while keeping frequently seen street assets immediately available.

4. Thin instances and packed geometry

In WebGL, the overhead of individual draw calls and scene graph node manipulation is the primary bottleneck. Placing 500 distinct Mesh objects in a Babylon Scene creates significant CPU overhead in matrix hierarchy updates and bounding box evaluations.

Allegiance bypasses scene nodes entirely by using Babylon Thin Instances (@babylonjs/core/Meshes/thinInstanceMesh).

Every mesh in the game shares a single vertex layout of 12 floats per vertex:

// src/engine/drawables.ts
export const VERTEX_FLOATS = 12;
// Layout: position (3), normal (3), uv (2), color (4)

Instance data is uploaded as raw packed Float32 arrays directly to GPU buffer attributes:

A single Drawable instance handles both individual dynamic entities (like the player's flying car) and instanced batches (like 200 trees, 50 streetlights, or a crowd of civilians):

// src/engine/drawables.ts
export class Drawable {
    readonly mesh: Mesh;
    // ...
    setRecords(records: Float32Array, count: number): void {
        this.mesh.thinInstanceSetBuffer("world0", records.subarray(...), 4, false);
        this.mesh.thinInstanceSetBuffer("world1", records.subarray(...), 4, false);
        this.mesh.thinInstanceSetBuffer("world2", records.subarray(...), 4, false);
        this.mesh.thinInstanceSetBuffer("world3", records.subarray(...), 4, false);
        this.mesh.thinInstanceSetBuffer("tint", records.subarray(...), 4, false);
        this.mesh.thinInstanceSetBuffer("texOrigin", records.subarray(...), 4, false);
        this.mesh.thinInstanceCount = count;
        this.mesh.isVisible = count > 0;
    }
}

Terrain chunks, city road ribbons, and buildings use the exact same thin instance drawing mechanism. This unifies the entire rendering pipeline into a predictable set of instanced draws.

5. Shaders: GLSL ES 3.00 and the 15-second Chrome freeze

QuadPlanet's original shader was written in WGSL for WebGPU. We ported it to GLSL ES 3.00 (src/engine/shaders/world_glsl.ts) to run on Babylon's WebGL 2 engine.

Family variants vs. monolithic uber-shaders

The world shader handles procedural terrain blending, PBR material maps, road markings, territory borders, crowd clothing colors, destruction smoke/fire, and shadow receiving.

Compiling all of these features into one massive uber-shader created unacceptable fragment shader complexity and hit performance walls on integrated graphics. We implemented family-specialized variants compiled with only the material branches each family needs:

// src/engine/renderer.ts
export const SHADER_FAMILIES = {
    terrain: [0, 1, 2],
    cityTiles: [8, 10],
    sky: [9],
    buildings: [3, 4, 5, 6, 7, 11],
    props: [3, 4, 5, 6, 7, 11, MAT_FOLIAGE],
    effects: [3, 4, 6, 11, MAT_FIRE, MAT_SMOKE],
    people: [3, 4, 5, 7, 12, 13, 14, 15, 16],
};

When creating a mesh, renderer.variant(kind, mask) inspects the material IDs present in the mesh's vertex buffer and assigns the smallest shader variant that covers them.

The ANGLE loop unrolling trap

During our first end-to-end runs in Google Chrome on Windows, we hit a severe bug: the loading screen froze for 15.2 seconds during the initial shader compilation pass.

Investigating Chrome's chrome://gpu logs and the DirectX shader compiler revealed the culprit. Chrome translates WebGL 2 GLSL into HLSL via ANGLE, targeting Direct3D 11. In our procedural lighting and cloud sampling loops, the loop bounds were compile-time constants (e.g. for (int i = 0; i < 4; i++)).

ANGLE's translator and the underlying D3D compiler attempted to aggressively unroll every single loop at every call site. Because our shader functions called helper noise and lighting routines inside nested loops, the resulting inlined HLSL ballooned to tens of thousands of instructions, choking the D3D shader compiler.

The fix was deceptively simple: force the compiler to treat the loop bound as dynamic so it cannot be statically unrolled:

// src/engine/shaders/world_glsl.ts
// Always 0 (sun_color.w is never set at runtime): added to loop bounds so the shader
// compiler keeps them as loops instead of unrolling every call site (which made
// Chrome's D3D compile take 15 seconds).
#define LOOP0 int(u_world[1].w)
 
// In loops:
for (int i = 0; i < 4 + LOOP0; i++) {
    // ...
}

Because u_world[1].w is a uniform value passed per-frame from CPU memory, the compiler cannot prove its value at compile time and is forced to emit a standard hardware jump/branch loop.

With this single macro change, total shader compilation time plummeted from 15,200 ms to 134 ms.

6. Four-cascade directional sun shadows

To handle shadows ranging from small character details (0.5 m away) to entire city blocks (500 m away), we implemented a 4-cascade directional shadow system in src/engine/shadows.ts.

In Babylon.js, standard shadow generators create multiple separate render targets. We instead allocate a single 2D Texture Array render target with 4 layers:

// src/engine/shadows.ts
const rtt = new RenderTargetTexture(
    "sun-shadows",
    { width: size, height: size, layers: 4 },
    this.scene,
    {
        generateMipMaps: false,
        type: Constants.TEXTURETYPE_UNSIGNED_BYTE,
        samplingMode: Constants.TEXTURE_NEAREST_SAMPLINGMODE,
        format: Constants.TEXTUREFORMAT_RED,
    }
);
rtt.createDepthStencilTexture(
    Constants.LESS,
    true,
    false,
    1,
    Constants.TEXTUREFORMAT_DEPTH24,
    "sun-shadow-depth"
);

In the GLSL fragment shader, all four cascades are sampled through a single hardware comparison sampler:

uniform highp sampler2DArrayShadow shadow_maps;

Casters are assigned cascade ranges: civilians and small props only render into cascades 0 and 1, while tall buildings and towers render into all 4 cascades. Furthermore, static cascades update with staggered refresh rates, sparing the frame budget when the sun angle holds still.

7. The online MMO expansion

In the original Entropy version, campaigns were saved locally to a single JSON file. In this port, we converted Allegiance into an online-only MMO.

There is no offline mode: when the API cannot be reached, the game halts at the title screen or displays a red alert banner on the HUD, refusing to fall back to an unverified local state.

The title screen displaying server outage and retry options
The title screen displaying server outage and retry options

The MMO architecture is split across two services:

1. Indie Machine API (indie-machine/app/api/allegiance)

2. Realtime Cluster (allegiance-realtime)

Two players meeting in London in live headless browser tests
Two players meeting in London in live headless browser tests

Authoritative street combat validated by Cloudflare Durable Object room
Authoritative street combat validated by Cloudflare Durable Object room

Server-resolved territory conquest updating player maps live
Server-resolved territory conquest updating player maps live

8. Verification and first-party numbers

All claims about correctness and performance are verified by tests executed locally on Windows 11 with an Intel Core i5-12500 and integrated Intel UHD Graphics 770.

1. The Playwright BDD two-player suite

The end-to-end MMO experience is tested by tests/bdd/mmo.mjs, driving the Gherkin feature tests/features/mmo_two_players.feature across two concurrent headless Chrome instances:

Scenario: The title says plainly when the server can't be reached
  ok   title shows the outage and RETRY (0.8 s)
Scenario: An anonymous player founds a nation
  ok   founds Dawn Front in London (1.4 s)
Scenario: A second player arrives and they see each other
  ok   Iron Choir arrives and Ada sees Mara (1.9 s)
Scenario: A street fight is refereed by the room
  ok   Mara shoots Ada and damage is registered (1.1 s)
Scenario: Territory taken from another player
  ok   Mara's army takes London from Ada (2.2 s)
Scenario: An anonymous player keeps their nation with an account
  ok   Ada registers account and retains nation (1.3 s)

2. The Vitest unit suite

Running npm run test:unit executes 20 test suites covering procedural generation, navigation, vehicles, combat, destruction, and netcode:

Test Files  6 failed | 14 passed (20)
Tests       8 failed | 462 passed (470)
Duration    187.49s

The 8 failing tests are documented in the failure notes below: they are pre-existing assertions from the Entropy native engine expecting raw WGSL shader string tokens that were deliberately removed during the GLSL ES 3.00 port.

3. Production build output

Running npm run build (tsc --noEmit && vite build) executes cleanly with zero TypeScript errors in 2.66 seconds:

dist/index.html                                  1.43 kB │ gzip:   0.64 kB
dist/assets/earth_worker-CqXjN2VN.js            45.28 kB
dist/assets/mesh_worker-DrBzvjUb.js          1,032.18 kB (includes meshopt WASM)
dist/assets/index-CeSYAol7.js                2,136.29 kB │ gzip: 589.26 kB
✓ built in 2.66s

4. Realtime referee suite

Running npm test inside allegiance-realtime executes 10/10 passing unit tests for the combat referee in 143.8 ms:

✔ cells: a point lies inside its own cell's bounds (0.7ms)
✔ enu measures meters east, north and up (0.2ms)
✔ walking speed passes; a runner covering 200 m in a second is refused (0.3ms)
✔ standing outside the cell asks the client to change rooms (0.1ms)
✔ a rifle shot along the line hits the first body, not the one behind it (1.0ms)
✔ walls: a pellet stopped short by the shooter's wall hits nobody (0.2ms)
✔ refused shots: too fast, wrong weapon, from elsewhere, too many pellets (0.2ms)
✔ a shotgun spreads its pellets and adds their damage on one target (0.2ms)
✔ the dead are not hit (0.2ms)
ℹ tests 10, pass 10, fail 0 (143.8ms)

9. Decision log

10. Failure notes and known limits

  1. The 15-second ANGLE compile freeze. Inlining loops in GLSL caused ANGLE's D3D11 translator to unroll nested loops into tens of thousands of instructions. Solved by defining loop bounds with #define LOOP0 int(u_world[1].w), forcing dynamic loop generation and reducing compilation time from 15.2 s to 134 ms.
  2. The 8 pre-existing shader test failures. Eight unit tests in allegiance_materials.test.ts, allegiance_crowd.test.ts, and allegiance_visuals.test.ts fail in Vitest because they assert raw string tokens from the old WGSL shaders (e.g. expect(PEOPLE_SHADER).toContain("var<storage, read> instances:") or expect(b[5].resource).toEqual({ type: "SamplerRepeat" })). These tests were ported from the native engine and await updating to assert GLSL uniforms and texture bindings.
  3. Room cell edge boundary blindness. Durable Object rooms are strictly partitioned into 0.25° cells (with 0.01° hysteresis). If two players stand 20 metres apart across an exact cell boundary, they are in different Durable Objects and currently cannot see each other. The planned fix is subscribing clients to neighboring rooms when within 100 metres of an edge.
  4. Client-trusted army sizes. In the current netcode, while PvP takes are resolved server-side, the server still trusts the client-reported troop count for PvE regional conquests. We mitigate this with a rate limit of 30 claims per minute, but moving the full strategic simulation to a server-side validator is tracked in the backlog.
  5. Remote player vehicle representations. When a remote player enters a flying car or Cobra, they currently render as an overhead marker and minimap icon rather than a dynamic 3D vehicle mesh.

What's next

With the Babylon.js client running cleanly and MMO netcode verified end-to-end, the immediate roadmap includes:

  1. Production deployment. Deploying allegiance-realtime to Cloudflare Workers, provisioning a production Postgres database via Prisma, and hosting the client at allegiance.indie-machine.com.
  2. Neighbor room subscription. Allowing clients standing near cell edges to subscribe to adjacent room streams, eliminating boundary pop-in.
  3. Remote vehicle meshes and Cobra dogfights. Synchronizing full vehicle transforms and projectile flight paths between players in the air.
NEXT
Physically Modelled Instruments: A Tube, a Pair of Lips and a Shock Front