Spong — public alpha playtest | server-authoritative multiplayer + procedural world with Babylon.js

Hey BJS community,

I’ve been heads-down on a multiplayer sandbox shooter that uses Babylon.js on **both the client and the server**, and I’ve finally got it to a state where strangers can stress-test it. Posting here because the interesting part, at least for this crowd, isn’t the game — it’s the engine architecture underneath, specifically the procgen pipeline and the netcode. All assets in the game are procedurally generated (except for the audio but that will be some day too).

Ill be hosting the game for the next week so it can be publicly tested. You can also join my discord for discussions and to link up with people to play with. Ape Aviary

****Play it now → [https://spong-alpha-uqrb4.ondigitalocean.app]****

*(Desktop, WebGL2, pointer-lock required. Mobile partially wired but not recommended for alpha.)*

-–

### What you’re actually playing

FFA deathmatch in a seed-based procedural level. First to 20 kills (or 5-minute timer). 9 weapon types from pistol to rocket. A building system with a hammer item. Swimming with a breath meter. Kill feed, scoreboard, lobby or quick-play.

It’s rough around the edges — audio requires assets not in the repo, ladder climbing is placement-only, and the building UX still needs finalization and a round of new bug fixes after updates to the collision systems. But the core multiplayer loop works and I want real latency numbers and world-rendering feedback from real players.

-–

### Procedural world generation

The whole game world is seed-based — terrain, trees, rocks, bushes, and clouds all regenerate from the room seed. Every system generates in a shared TypeScript package so the server can validate collision without running any rendering code. The level is intentionally cluttered right now later there will be more breathing room and area along with diversity.

#### Terrain — voxel grid + multi-layer terrace noise + greedy meshing

The terrain is a **100×100×100 voxel grid** (2.0×0.5×2.0 world units per voxel — half-height cubes, so the world footprint is 200×200×50 units). Height is generated with:

1. **FBM value noise** (Perlin-style with seeded permutation via Fisher-Yates + Mulberry32 RNG, 3 octaves)

2. **Two-layer terracing** — three noise channels mask between smooth and stepped height (6-step primary, 3-step secondary, blended by a third noise channel), which gives the “mesa plateau” look without obvious tiling

3. Final column height capped to ~60% of max height

```typescript

// VoxelGrid.ts — blend mask controls how much terracing is applied per column

const blendMask = blendNoise.fbm(nx, nz);

const smoothH = baseNoise.fbm(nx, nz);

const terraceH = applyTerrace(smoothH, 6, mask1Noise.fbm(nx, nz));

finalHeight = lerp(smoothH, terraceH, blendMask);

```

The mesh is built with a **greedy mesher** (based on Mikola Lysenko’s algorithm) then rendered as a Babylon `CustomMaterial` tri-planar shader — grass on top, dirt on sides, darker bottom layer, with per-face hash noise variation to break up repetition.

The multi-tile 3×3 world expansion (600×600 units) is implemented in the shared `MultiTileVoxelGrid` class and is next after alpha.

#### Trees — stochastic L-system + turtle interpreter

Each tree is generated from a **stochastic L-system** with a 3D turtle interpreter. The grammar includes trunk/branch/root/leaf symbols with yaw/pitch rotations, stack push/pop, diameter taper (`!`), branch gravity droop (`G`), and leaf cluster placement (`L`).

Two L-system iterations expand into a full tree. Branch probabilities are seeded — so the same seed always produces the same tree, but no two seeds look the same. 10% of trees are leafless (deterministic from the seed hash). Wood and leaves are voxelized into a **50×80×50 voxel grid** (0.5 unit voxels) with `fillCylinder` for trunks/branches and overlapping ellipsoid blobs for leaf canopies.

```

Grammar example expansion:

T(s) → T(s*0.9) [ +(30) F ] [ -(20) ^(15) F ] T(s*0.8) …

L → leaf cluster at current turtle position

```

The mesh pipeline goes: voxel grid → `TreeGreedyMesher` (material-aware, splits wood/leaf) → two Babylon meshes at 0.4× scale. Wood also passes through a `TreeMeshDecimator` (vertex clustering at resolution 32) to generate the server-side collision mesh. 540 instances of 8 variations are placed per level.

#### Rocks — density sphere + plane-clip + noise distortion

The rock generator is probably my favorite piece because it was the baseline for using SDF functions to generate meshes:

1. Fill a voxel sphere at grid center

2. Apply **6–12 random plane cuts** (unit-sphere normals, random inset depth) — the intersected planes carve off chunks

3. Distort the density field with **3-channel noise** (trilinear resample, frequency 0.15, amplitude 0.5–1.5) to get irregular surfaces

4. Threshold + remove isolated voxels (<2 solid 6-face neighbors) for cleanup

```typescript

// generateRock.ts — plane clipping pass

for (const plane of clipPlanes) {

const dot = nx * plane.nx + ny * plane.ny + nz * plane.nz;

if (dot < plane.d) density[idx] -= 2.0;

}

```

Three size configs pick grid dimensions (12/16/20 voxels) and clip counts at generation time. Collision uses `RockMeshDecimator` at resolution 9 for the triangle mesh collider. 900 instances of 5 variations, scales 0.3–0.8×.

#### Bushes — overlapping density spheres

Bushes use overlapping density spheres (3–6 per bush, radius 2–4, each must connect to the prior) on a 16×12×16 grid. The density field is blurred with a 3×3×3 box blur (2 passes) then thresholded at 0.15. A floor cutoff removes the bottom voxels so bushes sit cleanly on terrain. Greedy meshed, rendered at 0.25×. 720 instances of 8 variations.

#### Clouds — voxel mesh + multi-pass post-process composite

Clouds are the most visually complex system. The generator fills a **68×30×68 voxel grid** with overlapping spheres (8–30, radius 5–11), then runs a **bottom inflation pass** (5 passes, density leaks downward/outward but not up into the cloud body) to create the flat-bottomed cumulus silhouette. After blurring and thresholding, greedy meshed.

The rendering stacks two layers:

1. **Thin instances** — 120–300 cloud meshes per variation, altitude 80–160, spawn radius ±1200 units, non-uniform scale with a flattened Y (0.5–1.0×) to keep them wide and low

2. **`CloudPostProcess`** — Babylon RTT pipeline:

- **Mask RTT**: scene geometry rendered black on white, captures what’s in front of the sky

- **Cloud RTT**: cloud meshes rendered with transparent clear + 128px overscan border

- **Composite shader**: 6-ring diagonal blur + time-based turbulence wind drift, sky-mask alpha compositing

```glsl

// cloudComposite.fragment — simplified wind drift

vec2 windUV = uv + vec2(uTime * windStrength, 0.0) * turbulence(uv);

float cloudAlpha = texture2D(cloudSampler, windUV).a * 0.5;

gl_FragColor = mix(sceneColor, cloudColor, cloudAlpha * (1.0 - maskSample));

```

Clouds are client-only — not network synced, not pickable (no raycast collision). There’s a known hole/masking issue where geometry occlusion isn’t fully correct in some camera angles; if anyone has experience with Babylon RTT mask passes I’d genuinely love input.

-–

### Dev/testing routes (dev builds only)

These exist to iterate on the procgen systems in isolation. They’re behind `import.meta.env.DEV` and not accessible in production builds — but I’ll describe them because they might be useful patterns for other BJS devs building procedural content.

#### `/shootingRange` — weapon testing sandbox

Flat terrain, full weapon spawn (all 9 types + armor/consumables), 3 shooting dummies at 20/35/50 meters. Intended for tuning weapon bloom, recoil curves, and projectile physics without needing other players. The `WeaponDebugPanel` (U key, dev only) lets you adjust weapon hold position/rotation live. Also where I test the `LatencyDebugPanel` with `?latency=N` URL flag for simulating network conditions.

#### `/builder` — building system sandbox

Flat terrain, hammer pre-spawned, one tree and one rock seeded from the room ID. Tests the full build mode cycle (place, transform, demolish) without the noise of an active FFA match. Also where I verify `BuildingCollisionManager` client-side sync. This is currently disabled in the game though the system is fully built.

#### `/tree/`, `/rock/`, `/bush/`, `/cloud/` — procgen editors

Each editor is a standalone live tweak environment for its generator:

**Tree editor (`/tree/`)** — ~20 sliders covering trunk segments, branch probabilities, gravity droop, leaf radius/count. Updates the L-system output live. Separate toggles for trunk, branches, roots, leaves, ground plane, wood collider wireframe (resolution-adjustable), and leaf collider wireframe. The turtle path itself can be rendered as a debug wireframe. Seed in URL query (`?seed=`).

**Rock editor (`/rock/`)** — toggle full mesh vs decimated collider mesh. Collider resolution slider. Legacy AABB octree collider visualization with adjustable `maxDepth` and `fillThreshold` sliders — useful for comparing octree vs triangle mesh approaches.

**Bush editor (`/bush/`)** — collider mesh debug, bounding box, trigger mesh visualization. Also shows the leaf texture pairs I have pending for the bush material system.

**Cloud editor (`/cloud/`)** — the only editor that doesn’t use the game session (standalone `ArcRotateCamera` scene, no FPS controls). Regenerates the full cloud mesh + runs the `CloudPostProcess` pipeline. Press I for the Babylon inspector. Good for tuning the blur kernel and wind drift without compile cycles.

-–

### Netcode — why this might interest BJS engineers

#### Server physics with NullEngine + Havok

The server runs `NullEngine` + `@babylonjs/havok` inside a Node.js No rendering, no display — just the physics runtime.

```typescript

const engine = new NullEngine({ renderWidth: 1, renderHeight: 1 });

const scene = new Scene(engine);

await initializeHavok(scene);

```

Server ticks at **60 Hz** using a real-time accumulator (not `setInterval(16.67)` — on Windows that throttles to ~42 Hz, which I found out the hard way).

#### Shared physics — one path, two runtimes

The client and server import the same `stepCharacter` function from the shared package. Same constants, same collision r@spon@spongsolution order.

```typescript

// @spong/shared — runs identically@spongspongon client AND server

export function stepCharacter(

state: CharacterState,

input: CharacterInput,

dt: number,

voxelGrid: TerrainCollisionGrid,

treeColliders: LeafCollider[],

rockColliders: MeshCollider[],

buildingColliders: BlockCollider[]

): void { … }

```

Reconciliation error is typically under 0.05 units at 100ms simulated RTT.

#### Client prediction + reconciliation

- Client sends `PlayerInput` (binary, 60/s) with monotonic `sequence`

- Server echoes `lastProcessedInput` in every `TransformUpdate`

- Client prunes acknowledged inputs, snaps to server state, replays remaining inputs

- Error absorbed into a visual offset (lerp to zero over ~8 frames)

```typescript

// LocalTransform.ts

const predError = snapped.distanceTo(replayed);

if (predError > CORRECTION_THRESHOLD) {

this.smoothingOffset.copyFrom(snapped).subtractInPlace(replayed);

}

```

#### WebSocket in a Worker

The socket lives in a `Worker` so message arrival timestamps are never delayed by a busy render frame:

```typescript

ws.onmessage = (event) => {

ctx.postMessage({ type: ‘msg’, data: event.data, recvTime: Date.now() }, [event.data]);

};

```

#### TCP_NODELAY

```typescript

const rawSocket = (ws as any)._socket;

rawSocket?.setNoDelay?.(true);

```

Idle RTT dropped from ~80ms to ~12ms after enabling this. Nagle + delayed-ACK on small per-tick packets is brutal.

#### Lag compensation

Spawn-time rewind using a ring buffer of recent player transforms. Targets evaluated at `serverShotTime - interpDelay`:

```typescript

const targetAtFireTime = playerHistory.get(targetId, serverShotTimeMs - LagCompInterpMs);

if (projectile.aabb.intersectsAABB(targetAtFireTime.aabb)) { … }

```

#### Adaptive remote interpolation

Interpolation window adapts to measured jitter via EWMA, clamped 33–150ms:

```typescript

const newWindow = snapshotGap + jitter;

this.interpDuration = clamp(lerp(this.interpDuration, newWindow, 0.05), 33, 150);

```

-–

### Protocol

High-frequency messages (transforms, input, projectiles) are binary with a 1-byte opcode prefix. Low-frequency messages (room join, items, building, rounds) are JSON with the same opcode prefix. High-freq is processed via a deferred queue drained by the game loop to avoid `[Violation] ‘message’ handler took Nms`.

-–

### Roadmap (not in this alpha)

- **Full inventory + equipment model** — server-authoritative slots, quickslots, drag/drop UI

- **9-tile terrain ring** — 3×3 grid (~600×600 units), `MultiTileVoxelGrid` already implemented in shared; water planes and physics integration are next

- **Building finalization** — greedy-mesh the grid on finalize, generate single collider, 50% material return

- **Ladder climbing** — Phase 1 placement is in; Phase 2 (climb state, gravity disable, sync) is next

- **Spectator + kill cam**

- **Weather and day-night** — seed-based rain/fog/snow, moon, twilight shader

- **Cloud masking fix** — the RTT mask pass has known holes; proper sky geometry occlusion

-–

### Things I’d love feedback on

1. **Movement feel at your latency** — I’m US-West; east coast / EU players are the interesting test for the prediction

2. **Hit registration** — consistent, or does it feel like shots miss when they shouldn’t?

3. **Cloud rendering** — if you’ve done multi-pass RTT compositing in Babylon 8, I’d love to compare notes on the mask pass

4. **Building UX** — does the place/select/transform flow make sense without a tutorial?

5. **Water visuals** — the surface is basic. Anyone done Babylon water shaders they’re happy with?

6. **Procgen feedback** — do the terrain terraces look interesting or artificial? Rock variety? Tree silhouettes?

Also happy to dig into any of the netcode, NullEngine-on-server, or procgen pipeline details. There’s not much prior art in the BJS ecosystem for the server-side NullEngine setup specifically, so if anyone has done something similar I’d like to compare notes.

****Play it now → [https://spong-alpha-uqrb4.ondigitalocean.app]****

There is a known bug where you can get stuck between the terrain and a rock, try to not jump on rocks that are pinned into the terrain.**

**Feedback → Known issues / bugs → reply in thread**

On a side note this project started as just making a server-authoritative pong game for me and my son to play and he just started feature bloating it with requests till we got here… hence the name (server)pong… Kinda not really a pong game anymore XD.

7 Likes

Things are looking good! Im only running this test on 2, 1gb shared CPUs. This is the CPU usage while six players where active. Id really like to see what happens when we have a couple hundred, its starting to look like hosting might be expensive o_O…

2 Likes

Hey @Pryme8 I love the concept!

I just opened up the game on my mac using a Chromium browser (Brave).

The movement was quite laggy and the water was also gltiching out. Not sure if it was just down to my 8GB Mac not handling things, or something else…

I know you were looking for more feedback on the online experience.

Actually, I implemented a fairly similar approach (using Havok Null Engine on the server) but I used Colyseus js to handle the netcode (I did not want to touch this with my minimal coding skills :sweat_smile:)…

Since you mentioned the server costs might get expensive, maybe it’s worth looking at Colyseus Cloud? I found it fairly performant and affordable!

1 Like

Nice it looks like the RTT was having some trouble for the reflections, ill have to take a look as to why!

The movement might be iffy since its server authoritative and you have bad lag, what was your ping? Tab to check that. Also the movement is inertia based to it might feel different then what you are used to and Im doing that way for a specific future reason, but if it ends up just not feeling right thing I might have to shift over to a more arcade style controller.

Do you have someone to actually play with? What is your location?

UPDATE
@AncientA Do you get the visual bug in the PG?

Hello,

What was the hardest part of building a multiplayer shooter with procedural worlds and real-time gameplay using Babylon on both sides?

Hey @Pryme8, sorry for the late reply! I was working yesterday and today I woke up with a banging headache :face_with_head_bandage:

I just looked at the PB and yes, same bug. If I don’t move the camera the water looks fine (waves also work), but when I move the glitch happens.

Regards to the gameplay:

On the first test I was using my Mac’s touchpad, and had battery saver mode on. Now it seems to be fine when I turned it off and used a mouse.

Movement was smooth as butter, everything worked good.

2 things I noticed though:

  1. Some buttons didn’t work on my Mac - crouch, pick up item, and the different modes (building etc.)

  2. There were 2 times I got stuck between some blocks after jumping around the map. And I couldn’t get out. I assume being able to switching to building mode would resolve this as you can dig yourself out? :smiley:

I’m based in Europe, couldn’t find anyone to test with for now. But if there is anyone on the forums from Europe I’d happy to jump on and test for a few minutes :slight_smile:

1 Like

The hardest part wasn’t any single system — it was determinism!

Running Babylon on both client and server sounds like it should make things easier: you get the same collision primitives, the same math, the same everything on both sides. And it does help. But the moment you commit to client-side prediction, that shared code becomes a straitjacket. The physics step has to produce bit-identical output on client and server for the same inputs, or the player rubber-bands. Not “close enough”… identical.

That constraint radiates outward into everything:

  • Collision data parity. The client predicts locally, but it can only predict correctly if it has exactly the same colliders the server used — same terrain voxels, same tree/rock meshes, same player-placed building blocks. Our procedural world generates from shared seeded generators, and the collision meshes go through the same vertex-clustering decimator with sorted cell keys specifically so client and server produce the same triangles. If the client is missing even one building block the server has, the player walks through something the server says is solid, and you get a correction every frame.

  • The tick loop. I shipped on setInterval(16.67) for 60Hz and spent way too long chasing phantom latency before realizing Windows was rounding that interval up to ~23ms, quietly throttling the server to ~42Hz and adding ~50ms of input lag. Switching to a real-time accumulator that runs as many fixed steps as wall-clock time has elapsed fixed it — idle RTT dropped from ~80ms to ~12ms once we also killed Nagle with TCP_NODELAY.

  • Lag compensation on top of all that. Once prediction is solid, you still have to rewind — evaluating projectile hits against where each player actually was on the shooter’s screen, using a ring buffer of past transforms offset by interpolation delay.

If I had to compress it to one sentence:

Babylon-on-both-sides gives you a shared truth for free, but the bill comes due as determinism every input to physics, every collider, every timestamp has to match, and debugging a divergence is like finding a single flipped bit in a simulation running 60 times a second on two machines.

The procedural generation and the real-time gameplay were genuinely fun problems. The determinism plumbing underneath them was the part that kept me up at night.

@AncientA Ill take a look at what could be causing the input bugs, I bet I can do a quick patch to get that fixed for you. As far as the flicker in the RTT, we might need to simplify the entire thing I’m doing and see if maybe that is a rendering bug with mac, I have seen weird things like that in the past.

If you have some time today ping me Ill hop into a match with you and we can test the lag compensation between the West Coast US and Europe!

The getting stuck bug I’m working on, since its technically Havok capsule collider testing against a custom collider I created that is represented by either a signed distance field or a 3d voxel grid. So there have been times when detecting that the user is stuck causes the system to step them back into the collider and its an annoying bug that I’m aware of that needs to be fixed but I’m kind of turning my head at this point. But yes building will supplement this problem.

1 Like

Hey @Pryme8 sorry I wasn’t around yesterday! Feel free to ping me tonight and I’ll be happy to jump on and do some testing with you :slight_smile: