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 × 20interior room (halfW = 14,halfD = 10) walled on four sides withwallHeight = 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 everyPORTAL_LIFETIME = 18s (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
PortalArenaSimruns 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.tsis 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.parseof the union types. The server ticksPortalArenaSimat1000 / PORTAL_ARENA_TICK_HZms (≈33 ms) viasetInterval, and after every tick broadcasts onesnapshotmessage to every open socket — but skips the broadcast entirely whenconns.size === 0. - Client→server input is rate-limited below the tick rate:
portal_arena_connection.tsdefinesINPUT_SEND_HZ = 20, sosendInputdrops any call that arrives within1000/20 = 50ms of the last one.firemessages are not throttled by the transport — the sim'sFIRE_COOLDOWNis 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:
- Socket opens; client sends
{ t: 'join', name }. - Server sanitizes the name (
MAX_NAME_LEN = 16), callssim.addPlayer(), stores the returnedplayerIdon the connection, and replies with{ t: 'init', selfId, seed, colorIndex }. - Until
joinarrives, all other messages are ignored ("must join first"). input→sim.setInput(playerId, …);fire→sim.fire(playerId, …).- 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.
setInputjust clamps(x, z)into[-(halfW-PLAYER_RADIUS), …]and storesfacing. 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 onfireCooldown, normalizes the direction, and spawns the laserPLAYER_RADIUS + 0.4units ahead of the cone so it clears the shooter's own circle. _updateLasersadvances each laser byLASER_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 a0.3sportalCooldownprevents immediate re-entry. Walls/obstacles destroy the laser; a player withinLASER_RADIUS + PLAYER_RADIUSand not onhitCooldowntakes a hit (_damageremoves a heart, setshitCooldown = HIT_COOLDOWN, and on 0 hearts marks the player dead withrespawnIn = RESPAWN_SECONDS). Note the owner can hit themselves after a portal loop._updateRoundonly treats play as competitive with ≥2 players; when an active round drops to one survivor that player'swinPointsis incremented andlastWinnerIdis 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:
Client-side prediction (local player).
portal_arena.tskeeps apredicted { x, z, facing }struct. Each frame it integrates WASD input atPLAYER_SPEED * dt, resolves collision locally withcircleVsBoxes(...collisionBoxes)(walls + obstacles), applies portal teleports, thenconnection.sendInput(...)reports the result. The local cone is rendered frompredicted, 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,predictedis seeded from the snapshot. - On a death→alive transition (
!wasAlive && self.alive),predicted.x/zare snapped to the server-chosen respawn point.
- On first sight of
Snapshot interpolation (remote players).
NetPortalArenaConnectionkeeps the two most recent snapshots (_snapshot/_prevSnapshot) with their arrival wall-clock times.getRenderSnapshot()computest = clamp((now - snapshotTime) / interval, 0, 1)and lerps every remote player'sx,zandfacing(lerpAngledoes 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 at1means a late snapshot freezes rather than overshoots. Game logic still reads the rawgetSnapshot(); only rendering uses the interpolated copy.Laser extrapolation.
getExtrapolation()returns seconds elapsed since the current snapshot, clamped to2 / PORTAL_ARENA_TICK_HZ.PortalArenaRenderer.buildadvances each laser byLASER_SPEED * extrapolationalong 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 a2048²shadow map for the single cascade. - GeometryPass rasterizes all opaque
drawItemsinto 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 leans0.28rad toward its heading. The local player uses thelocalOverride(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.
portalModelbuilds 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_HEARTSheart glyphs; filled red up to the player'shearts, dim gray beyond. - Respawn overlay — center screen when
self.aliveis false, showingELIMINATEDand aMath.ceil(respawnIn)countdown. - Scoreboard — top-right, players sorted by
winPointsdescending, each row a colored dot (paletteCss), name (HTML-escaped),(you)/☠markers and the score. A header showsROUND LIVEvsWARMUPfromroundActive. - Connection badge — bottom-left:
ONLINE(green),LOCAL · SINGLE PLAYER(gray) orDISCONNECTED(red), driven byconnection.mode/connection.closed. - Round-winner banner — shown ~4 s when
snapshot.lastWinnerIdchanges. The HUD tracks_shownWinnerand 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:
ctx.update();dt = min(ctx.deltaTime, 0.05)(clamped against stalls).connection.getSnapshot()→ findselfbyconnection.selfId.- Predicted-state sync — seed
predictedon first sight ofself; re-snappredicted.x/zon a death→alive transition. - Decay
portalCooldownandfireCooldown; read the shoot intent. - Client prediction (alive only): integrate WASD movement, resolve
collision with
circleVsBoxes, apply local portal teleports (portalCooldown = 0.6after a jump), thenconnection.sendInput(...). If shooting and offfireCooldown,connection.fire(...)and reset the cooldown toFIRE_COOLDOWN. connection.update(dt)— no-op online, fixed-steps the sim locally.- Recompute
camera.orthoSizefor the current aspect,camera.updateRender, setctx.activeCamera. - Build draw items:
renderer.build(connection.getRenderSnapshot(), localOverride, connection.getExtrapolation()). Note the mix —getRenderSnapshot()(interpolated remotes) for everyone, butlocalOverrideoverrides this client's own cone with the predicted transform. - Push uniforms into each pass, build/compile/execute the
RenderGraph. hud.update(...); update the#statsline (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.tsis notshared/net_protocol.ts— different message sets and versions. They're hosted by the same unified server on port8787, routed by path (/portal_arenavs/crafty), so each protocol stays untouched. Don't cross the wires. - Dependency-free shared code.
portal_arena_protocol.ts,portal_arena_sim.tsandportal_arena_layout.tsmust not import fromsrc/— they are type-checked by the server's own tsconfig and bundled for the browser by Vite. The arena rolls its ownmulberry32PRNG andMat4-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
PortalArenaSnapshotwith 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
emissiveFactordoes nothing unless anemissiveMapis 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.
_spawnPortalPairchooseswallBas a rotated offset fromwallA((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.