Taos Engine

Games

Portal Arena — Technical Deep Dive

Portal Arena is a 3/4 top-down multiplayer arena shooter built on the Taos Engine 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:

High-level client/server split:

Architecture — file map#

FileResponsibility
portal_arena.htmlEntry page: a single <canvas>, a #stats line, a #desc hint, plus the shared lib/source_viewer.ts overlay.
portal_arena.tsThe 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.tsTransport 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.tsPortalArenaRenderer — 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.tsPortalArenaHud — plain-DOM HUD: hearts, respawn countdown, scoreboard, connection badge, round-winner banner.
shared/portal_arena/portal_arena_protocol.tsWire protocol + gameplay constants. Dependency-free; shared verbatim by client, local host and server.
shared/portal_arena/portal_arena_sim.tsPortalArenaSim — the authoritative simulation. Pure logic, no rendering/DOM/networking.
shared/portal_arena/portal_arena_layout.tsgeneratePortalArena(seed) — deterministic room geometry; circleVsBoxes / circleHitsBox collision; mulberry32 PRNG.
server/src/modules/portal_arena/portal_arena_module.tsPortal 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:

ConstantValueMeaning
PORTAL_ARENA_PORT8788Legacy standalone port. The unified server now hosts the arena on 8787 at /portal_arena.
PORTAL_ARENA_TICK_HZ30Sim tick rate and server snapshot broadcast rate.
PORTAL_ARENA_PROTOCOL_VERSION1Bumped on incompatible message changes.
PLAYER_SPEED7Walk speed (units/s).
LASER_SPEED22Laser travel speed (units/s).
FIRE_COOLDOWN0.28Minimum seconds between shots (server-enforced).
HIT_COOLDOWN0.6Invulnerability window after taking a hit.
LASER_LIFETIME5Strays self-destruct after 5 s.
PORTAL_LIFETIME18Seconds before a portal pair relocates.
RESPAWN_SECONDS10Death timer.

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

TypeScript
// 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#

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. input → sim.setInput(playerId, …); fire → sim.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):

TypeScript
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:

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:

TypeScript
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());

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:

TypeScript
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:

TypeScript
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:

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:

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.

Shell
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#