Taos Engine ▦ Taos: Building a Modern WebGPU Game Engine

Portal Arena — Technical Deep Dive

Portal Arena is a 3/4 top-down multiplayer arena shooter built on the Taos WebGPU engine. You drive a cone-shaped avatar around a walled, ceiling-less room, shoot HDR-emissive laser beams at other players, and dodge through emissive portal pairs that periodically relocate on the walls. It exercises the deferred render-graph pipeline (shadow → gbuffer → deferred lighting → forward → tonemap), an orthographic camera, IBL ambient from a baked procedural sky, and a server-authoritative WebSocket multiplayer model with client prediction and snapshot interpolation — all with a graceful offline single-player fallback.

Overview#

What you see and do:

  • A 28 × 20 interior room (halfW = 14, halfD = 10) walled on four sides with wallHeight = 3, sprinkled with 5–7 procedurally placed obstacle boxes.
  • Your player is a colored cone that leans toward its facing direction. You move with WASD / arrow keys and shoot with Space or left-click.
  • Lasers are short emissive beams. A hit costs one heart; lose all three (MAX_HEARTS = 3) and you are eliminated for a 10-second respawn timer (RESPAWN_SECONDS = 10).
  • Two portal pairs (PORTAL_PAIRS = 2) glow on the walls — cyan and amber. Walk or shoot through one end and you (or your laser) emerge from its partner. Each pair relocates every PORTAL_LIFETIME = 18 s (plus 0–6 s of jitter).
  • Outlast everyone in a round and you score a win point; a banner announces the winner.

High-level client/server split:

  • Authority lives in a single shared class, PortalArenaSim (shared/portal_arena/portal_arena_sim.ts). It owns combat, damage, death/respawn, rounds, portal spawning and laser-through-portal redirection. Crucially, player movement is client-driven: the sim simply stores whatever transform the client reports (clamped to the room) — matching the repo's existing voxel networking model.
  • The same PortalArenaSim runs in two places: inside the unified server's Portal Arena module (server/src/modules/portal_arena/portal_arena_module.ts) for online play, and inside the browser tab itself (LocalPortalArenaConnection) as a single-player host when no server answers. Online and offline play therefore run bit-for-bit identical rules.
  • portal_arena.ts is purely the client: camera, input, client-side prediction, rendering and HUD. It never imports the server.

Architecture — file map#

File Responsibility
portal_arena.html Entry page: a single <canvas>, a #stats line, a #desc hint, plus the shared lib/source_viewer.ts overlay.
portal_arena.ts The sample's main(): creates the RenderContext, connects, builds the deferred pass chain, sets up the camera/light/sky, runs the per-frame loop with input + client prediction.
portal_arena/portal_arena_connection.ts Transport abstraction. PortalArenaConnection interface plus two implementations — NetPortalArenaConnection (WebSocket, over the shared GameSocket) and LocalPortalArenaConnection (in-browser sim). connectPortalArena() picks one. Owns snapshot interpolation.
portal_arena/portal_arena_render.ts PortalArenaRenderer — turns an PortalArenaSnapshot into render-graph draw items. Owns the meshes (cube, cone, torus) and PBR materials. Builds static geometry once; rebuilds players/lasers/portals per frame.
portal_arena/portal_arena_hud.ts PortalArenaHud — plain-DOM HUD: hearts, respawn countdown, scoreboard, connection badge, round-winner banner.
shared/portal_arena/portal_arena_protocol.ts Wire protocol + gameplay constants. Dependency-free; shared verbatim by client, local host and server.
shared/portal_arena/portal_arena_sim.ts PortalArenaSim — the authoritative simulation. Pure logic, no rendering/DOM/networking.
shared/portal_arena/portal_arena_layout.ts generatePortalArena(seed) — deterministic room geometry; circleVsBoxes / circleHitsBox collision; mulberry32 PRNG.
server/src/modules/portal_arena/portal_arena_module.ts Portal Arena game module for the unified server: one global PortalArenaSim, ticked and broadcast.

Note that the arena uses its own protocol (shared/portal_arena/), entirely separate from the voxel game's shared/net_protocol.ts. The two carry different state (cones/lasers/portals vs. blocks/players). They are now hosted by the same unified server (server/src/main.ts) on the shared port 8787, routed by URL path: the voxel game on /crafty, the arena on /portal_arena.

Networking#

Protocol and constants (shared/portal_arena/portal_arena_protocol.ts)#

Key constants:

Constant Value Meaning
PORTAL_ARENA_PORT 8788 Legacy standalone port. The unified server now hosts the arena on 8787 at /portal_arena.
PORTAL_ARENA_TICK_HZ 30 Sim tick rate and server snapshot broadcast rate.
PORTAL_ARENA_PROTOCOL_VERSION 1 Bumped on incompatible message changes.
PLAYER_SPEED 7 Walk speed (units/s).
LASER_SPEED 22 Laser travel speed (units/s).
FIRE_COOLDOWN 0.28 Minimum seconds between shots (server-enforced).
HIT_COOLDOWN 0.6 Invulnerability window after taking a hit.
LASER_LIFETIME 5 Strays self-destruct after 5 s.
PORTAL_LIFETIME 18 Seconds before a portal pair relocates.
RESPAWN_SECONDS 10 Death timer.

Message types are small, JSON-encoded discriminated unions keyed on t:

// Client → server
type PortalArenaC2S =
  | { t: 'join'; name: string }
  | { t: 'input'; x: number; z: number; facing: number }
  | { t: 'fire'; x: number; z: number; dirX: number; dirZ: number };

// Server → client
type PortalArenaS2C =
  | { t: 'init'; selfId: number; seed: number; colorIndex: number }
  | { t: 'snapshot'; snapshot: PortalArenaSnapshot };

The PortalArenaSnapshot is the entire authoritative state for one tick — all players, all lasers, all portals, plus tick, roundActive and lastWinnerId. There is no delta compression: the room is small enough that sending the whole snapshot 30×/s is cheap, which keeps the protocol trivial. The shared PlayerState, LaserState and PortalState interfaces double as both the in-memory shapes and the wire format — serialization is just JSON.stringify.

Serialization and tick rate#

  • Server: JSON.stringify / JSON.parse of the union types. The server ticks PortalArenaSim at 1000 / PORTAL_ARENA_TICK_HZ ms (≈33 ms) via setInterval, and after every tick broadcasts one snapshot message to every open socket — but skips the broadcast entirely when conns.size === 0.
  • Client→server input is rate-limited below the tick rate: portal_arena_connection.ts defines INPUT_SEND_HZ = 20, so sendInput drops any call that arrives within 1000/20 = 50 ms of the last one. fire messages are not throttled by the transport — the sim's FIRE_COOLDOWN is the real limiter.

The authoritative-server model#

The Portal Arena module (server/src/modules/portal_arena/portal_arena_module.ts) is intentionally minimal: one global arena room, no lobby, no persistence. Each connection becomes a room member { session, playerId }. The lifecycle:

  1. Socket opens; client sends { t: 'join', name }.
  2. Server sanitizes the name (MAX_NAME_LEN = 16), calls sim.addPlayer(), stores the returned playerId on the connection, and replies with { t: 'init', selfId, seed, colorIndex }.
  3. Until join arrives, all other messages are ignored ("must join first").
  4. inputsim.setInput(playerId, …); firesim.fire(playerId, …).
  5. On socket close, sim.removePlayer(playerId).

The WebSocket ping/pong heartbeat (every HEARTBEAT_MS = 15_000 ms; terminate if no pong arrives within 2 ×) and SIGINT / SIGTERM graceful shutdown now live in the shared GameHost, not the module. The seed comes from process.env.SEED or a random 32-bit value, so a given server instance always has one fixed arena layout.

How the server simulates (PortalArenaSim.tick)#

Every tick (dt = 1/30):

tick(dt) {
  this._tick++;
  // decay each player's hitCooldown / fireCooldown
  this._updatePortals(dt);   // relocate pairs whose timer expired
  this._updateLasers(dt);    // advance, portal-redirect, collide, damage
  this._updateRespawns(dt);  // count down dead players, respawn at random point
  this._updateRound();       // detect last-player-standing, award win points
}

Authority split worth noting:

  • Movement is not simulated. setInput just clamps (x, z) into [-(halfW-PLAYER_RADIUS), …] and stores facing. The client owns its own position; the server only bounds-checks it. There is no server-side movement collision against obstacles — that is done client-side (see below).
  • Combat is fully authoritative. fire() rejects shots from dead players or players still on fireCooldown, normalizes the direction, and spawns the laser PLAYER_RADIUS + 0.4 units ahead of the cone so it clears the shooter's own circle.
  • _updateLasers advances each laser by LASER_SPEED * dt, then checks portal crossing before walls (so a portal on a wall takes priority over that wall). On a portal hit the laser is teleported to the partner, its direction is set to the partner's inward normal, and a 0.3 s portalCooldown prevents immediate re-entry. Walls/obstacles destroy the laser; a player within LASER_RADIUS + PLAYER_RADIUS and not on hitCooldown takes a hit (_damage removes a heart, sets hitCooldown = HIT_COOLDOWN, and on 0 hearts marks the player dead with respawnIn = RESPAWN_SECONDS). Note the owner can hit themselves after a portal loop.
  • _updateRound only treats play as competitive with ≥2 players; when an active round drops to one survivor that player's winPoints is incremented and lastWinnerId is set.

The arena layout itself is deterministic: generatePortalArena(seed) and the portal RNG (mulberry32(seed ^ 0x9e3779b9)) mean the server and every client compute the same walls, obstacles and spawn points from the single shared seed delivered in init.

Client prediction, interpolation, reconciliation (portal_arena_connection.ts)#

The PortalArenaConnection interface hides whether the authority is remote or local. connectPortalArena(name, timeoutMs = 1500) tries NetPortalArenaConnection.connect first and, on any failure (timeout, socket error, early close), constructs a LocalPortalArenaConnection instead — so the sample always runs.

Three techniques combine to hide network latency:

  1. Client-side prediction (local player). portal_arena.ts keeps a predicted { x, z, facing } struct. Each frame it integrates WASD input at PLAYER_SPEED * dt, resolves collision locally with circleVsBoxes(...collisionBoxes) (walls + obstacles), applies portal teleports, then connection.sendInput(...) reports the result. The local cone is rendered from predicted, so it responds with zero latency. Because the server merely stores reported transforms (no rejection of valid moves), prediction never disagrees with authority and there is no rubber-banding — "reconciliation" reduces to a couple of explicit re-snaps:

    • On first sight of self, predicted is seeded from the snapshot.
    • On a death→alive transition (!wasAlive && self.alive), predicted.x/z are snapped to the server-chosen respawn point.
  2. Snapshot interpolation (remote players). NetPortalArenaConnection keeps the two most recent snapshots (_snapshot / _prevSnapshot) with their arrival wall-clock times. getRenderSnapshot() computes t = clamp((now - snapshotTime) / interval, 0, 1) and lerps every remote player's x, z and facing (lerpAngle does shortest-path yaw interpolation). Remote players are thus rendered ≈one tick in the past, always between two known positions, so they glide instead of stepping at 30 Hz. The clamp at 1 means a late snapshot freezes rather than overshoots. Game logic still reads the raw getSnapshot(); only rendering uses the interpolated copy.

  3. Laser extrapolation. getExtrapolation() returns seconds elapsed since the current snapshot, clamped to 2 / PORTAL_ARENA_TICK_HZ. PortalArenaRenderer.build advances each laser by LASER_SPEED * extrapolation along its travel direction so fast beams glide smoothly between ticks.

LocalPortalArenaConnection mirrors the interface: update(dt) fixed-steps its owned PortalArenaSim at 1/PORTAL_ARENA_TICK_HZ with an accumulator (capped at 8 steps to avoid a spiral of death). Single-player has nothing to interpolate, so getRenderSnapshot() just returns the live snapshot, and getExtrapolation() returns the accumulator.

Rendering (portal_arena/portal_arena_render.ts + portal_arena.ts)#

Pass chain#

The sample wires the deferred render-graph pipeline identically to rg_deferred_simple.ts. Five persistent pass instances are created once:

ShadowPass → GeometryPass → DeferredLightingPass → ForwardPass → TonemapPass

Per frame, a fresh RenderGraph is built and the passes are added with typed dependencies:

const graph = new RenderGraph(ctx, cache);
const bb     = graph.setBackbuffer('canvas');
const shadow = shadowPass.addToGraph(graph, { cascades, drawItems: shadowItems });
const gbuffer= geometryPass.addToGraph(graph);
const lit    = lightingPass.addToGraph(graph, {
  gbuffer, shadowMap: shadow.shadowMap, iblTextures: sky.iblTextures,
});
const composited = forwardPass.addToGraph(graph, {
  output: lit.hdr, depth: gbuffer.depth,
  loadOp: 'load', depthLoadOp: 'load', iblTextures: sky.iblTextures,
});
tonemapPass.addToGraph(graph, { hdr: composited.output, backbuffer: bb });
void graph.execute(graph.compile());
  • ShadowPass renders shadowItems (static geometry + player cones) into a 2048² shadow map for the single cascade.
  • GeometryPass rasterizes all opaque drawItems into the G-buffer.
  • DeferredLightingPass shades the G-buffer with the directional sun and IBL ambient into an HDR target.
  • ForwardPass composites the one semi-transparent near wall over the lit HDR (loadOp: 'load' keeps the deferred result; depth is loaded from the G-buffer so the wall depth-tests against the opaque scene).
  • TonemapPass maps HDR → the canvas backbuffer. updateParams(ctx, 1.0, false, false) sets exposure 1.0 with bloom/effects off.

A createRenderGraphViz overlay is attached (G key) so the compiled graph can be inspected live.

The 3/4 top-down camera#

The camera is a fixed orthographic GameObject:

const CAM_PITCH = 0.92;            // radians below horizontal — the "3/4" tilt
const camDist = 70;
cameraGO.setPosition(0, sin(CAM_PITCH)*camDist, cos(CAM_PITCH)*camDist);
cameraGO.setRotation(Quaternion.fromAxisAngle(new Vec3(1,0,0), -CAM_PITCH));
const camera = cameraGO.addComponent(Camera.createOrthographic(28, 1, 200, aspect));

It sits in the +Z/+Y corner and looks down into the room at CAM_PITCH (≈52.7°). Each frame camera.orthoSize is recomputed so the whole room stays framed at any window aspect ratio:

const viewW = 2*layout.halfW + 2*layout.wallThickness + 6;
const viewH = 2*layout.halfD*sin(CAM_PITCH) + layout.wallHeight*cos(CAM_PITCH) + 7;
camera.orthoSize = Math.max(viewH, viewW / aspect);

viewH accounts for the room depth foreshortened by the tilt plus the apparent wall height; the camera never moves or tracks the player.

Lighting and the baked sky#

The sun is static, so its shadow data is computed exactly once. lightDir = (0.45, -1, 0.32) normalized; a single CascadeData is built from an orthographic light projection (halfBox = 26) and Mat4.lookAt. Because the sun never moves, DynamicSky.create(ctx, 0.35) is baked once into IBL cubes (sky.bake(toward-sun, 200, true)) — the 0.35 iblExposure controls how bright the sky-ambient fill is, so shadowed areas are lifted off pure black rather than crushed. The same iblTextures feed both the deferred lighting pass and the forward pass.

Meshes, materials and per-frame draw items#

PortalArenaRenderer owns three meshes — Mesh.createCube, Mesh.createCone(0.55, 1.6, 24) and Mesh.createTorus(PORTAL_RADIUS, 0.17, 36, 14) — and a set of PbrMaterials. Static geometry (floor slab, three opaque walls, obstacles) is built once into _staticDraw / _staticShadow in _buildStatic.

build(snapshot, localOverride, extrapolation) returns fresh drawItems and shadowItems each frame:

  • Players — a cone per alive player, transformed by translation(x, 0.02, z) · rotationY(facing) · rotationX(CONE_TILT) so it leans 0.28 rad toward its heading. The local player uses the localOverride (latency-free prediction); everyone else uses the interpolated snapshot position.
  • Lasers — a stretched cube (scale(0.16, 0.16, 1.4)) per laser, positioned with the extrapolation offset, colored by the owner's palette entry.
  • Portals — an emissive torus per portal. portalModel builds an orthonormal basis that stands the +Y-axis torus up against its wall, mapping local +Y to the wall's inward normal.

Emissive trick worth noting: lasers and portals use a near-black albedo with a bright HDR emissiveFactor. Because the engine's default emissive map is black, a 1×1 white Texture.createSolid is bound as emissiveMap so emissiveFactor actually emits — the HDR values then bloom through the deferred lighting / tonemap chain. Laser emission is colorChannel * 3.6 + 0.4; portal pairs are hard-coded cyan [0.30, 1.7, 2.0] and amber [2.0, 0.75, 0.30].

The transparent near wall#

The wall closest to the camera (largest cz) would otherwise block the view of players and portals behind it. So it is excluded from the opaque static set and instead handed to the ForwardPass as a single ForwardDrawItem with a transparent: true material at alpha 0.28. Its geometry never changes, so forwardPass.setDrawItems(renderer.forwardItems) is called once at startup. The forward pass draws it after deferred lighting, blending it over the lit scene.

HUD (portal_arena/portal_arena_hud.ts)#

PortalArenaHud is plain DOM — fixed-position <div>s styled with inline CSS, matching the lightweight approach of crafty/ui/hud.ts. Its single update method refreshes every element from the latest snapshot:

  • Hearts — top-left, MAX_HEARTS heart glyphs; filled red up to the player's hearts, dim gray beyond.
  • Respawn overlay — center screen when self.alive is false, showing ELIMINATED and a Math.ceil(respawnIn) countdown.
  • Scoreboard — top-right, players sorted by winPoints descending, each row a colored dot (paletteCss), name (HTML-escaped), (you) / markers and the score. A header shows ROUND LIVE vs WARMUP from roundActive.
  • Connection badge — bottom-left: ONLINE (green), LOCAL · SINGLE PLAYER (gray) or DISCONNECTED (red), driven by connection.mode / connection.closed.
  • Round-winner banner — shown ~4 s when snapshot.lastWinnerId changes. The HUD tracks _shownWinner and deliberately suppresses a stale banner on join.
  • A static controls hint sits bottom-right.

paletteCss converts a linear-RGB PLAYER_PALETTE entry to a CSS rgb() string with an approximate pow(v, 1/2.2) sRGB encode, so HUD dots match the 3D cone colors.

Game loop (portal_arena.ts)#

Per requestAnimationFrame, frame() runs in this order:

  1. ctx.update(); dt = min(ctx.deltaTime, 0.05) (clamped against stalls).
  2. connection.getSnapshot() → find self by connection.selfId.
  3. Predicted-state sync — seed predicted on first sight of self; re-snap predicted.x/z on a death→alive transition.
  4. Decay portalCooldown and fireCooldown; read the shoot intent.
  5. Client prediction (alive only): integrate WASD movement, resolve collision with circleVsBoxes, apply local portal teleports (portalCooldown = 0.6 after a jump), then connection.sendInput(...). If shooting and off fireCooldown, connection.fire(...) and reset the cooldown to FIRE_COOLDOWN.
  6. connection.update(dt) — no-op online, fixed-steps the sim locally.
  7. Recompute camera.orthoSize for the current aspect, camera.updateRender, set ctx.activeCamera.
  8. Build draw items: renderer.build(connection.getRenderSnapshot(), localOverride, connection.getExtrapolation()). Note the mix — getRenderSnapshot() (interpolated remotes) for everyone, but localOverride overrides this client's own cone with the predicted transform.
  9. Push uniforms into each pass, build/compile/execute the RenderGraph.
  10. hud.update(...); update the #stats line (fps · mode · player count).

The key mapping insight: network state → rendered entities goes through two paths simultaneously — remote players are interpolated server state, the local player is prediction, and lasers are extrapolated server state.

Running it#

The arena is served by the same unified server as the voxel game — one crafty-server process, one entry script (server/src/main.ts), with the two games routed by path.

npm run server:install   # once — installs server/ dependencies
npm run server           # runs the unified server (server/src/main.ts) via tsx watch

npm run server maps to npm --prefix server run dev, which runs tsx watch src/main.ts. The server hosts both games on port 8787 and logs [host] listening on ws://localhost:8787 modules: /crafty, /portal_arena; the arena client dials the /portal_arena path.

Then start the dev server (npm run dev) and open http://localhost:5173/samples/portal_arena.html in two or more tabs. Each tab joins as Cone NNN (a random 3-digit suffix) and gets a distinct palette color. With no server running, every tab silently falls back to its own local single-player sim after the 1.5 s connect timeout — the badge then reads LOCAL · SINGLE PLAYER.

Override the unified server's port or the arena seed with the PORT / SEED environment variables.

Notable techniques and gotchas#

  • Two separate protocols, one server. shared/portal_arena/portal_arena_protocol.ts is not shared/net_protocol.ts — different message sets and versions. They're hosted by the same unified server on port 8787, routed by path (/portal_arena vs /crafty), so each protocol stays untouched. Don't cross the wires.
  • Dependency-free shared code. portal_arena_protocol.ts, portal_arena_sim.ts and portal_arena_layout.ts must not import from src/ — they are type-checked by the server's own tsconfig and bundled for the browser by Vite. The arena rolls its own mulberry32 PRNG and Mat4-free math for exactly this reason.
  • Movement authority is the client's. The server only clamps reported positions to the room bounds; it does not run obstacle collision. That is what makes prediction trivially correct (no reconciliation snapback) but also means the server trusts the client's position — fine for a demo, not for a competitive title.
  • Full-snapshot networking. Every tick ships the whole PortalArenaSnapshot with no delta encoding. Simple and robust because the room is tiny; would not scale to large worlds.
  • Render vs. logic snapshots. getSnapshot() is raw authoritative state for game logic (prediction, portal checks); getRenderSnapshot() is the interpolated copy for drawing. Mixing them up would make remote players step or local checks lag.
  • Emissive needs a white map. A material's emissiveFactor does nothing unless an emissiveMap is bound, because the engine's default emissive texture is black — hence the 1×1 white texture on lasers and portals.
  • Everything static is computed once. The room geometry, the sun cascade, the IBL sky bake and the near-wall forward item are all built at startup because none of them ever change — only players, lasers and portals are rebuilt per frame.
  • Portals pick different walls. _spawnPortalPair chooses wallB as a rotated offset from wallA ((wallA + 1 + rand(3)) % 4) so a pair's two ends are never on the same wall — stepping out of one end can't drop you straight back into its partner.
  • Laser portal priority. In _updateLasers, portal crossing is tested before wall collision, so a portal sitting on a wall correctly swallows a laser instead of the wall destroying it.