Skip to content

Repository files navigation

WebGame

Browser-based multiplayer first-person shooter, modelled on Counter-Strike: round-based matches with an authoritative server, client-side prediction and lag compensation — plus a collaborative in-game voxel editor for building maps while the match is running. Runs in the browser (Chromium/Firefox) and as an Electron desktop app.

Core principle: the server simulates authoritatively. The client predicts, reconciles and renders. Client and server run the same TypeScript for movement, collision and entities (shared/).


Licence

Copyright © 2026 Frederik Kirchhoff. Free software under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later).

AGPL rather than GPL because of how this program is used. The GPL's obligations trigger on distribution — but a game server is not distributed, it is operated: someone could rebuild the netcode, run it publicly, and owe nobody anything. Since shared/ runs on both sides, the same file would be covered in the browser and uncovered on the server. AGPL §13 closes that: a modified version made available over a network must offer its source to the people using it.

If you run a modified version, point the source link in the pause menu (client/index.html, #menu-source) at your source, not at the upstream project. That link is how this program satisfies §13, and it has to lead to the code that is actually running.

Scope: the code, not the content. The licence covers this repository's source. The assets it uses have their own terms and are not sublicensed by it — the ambientCG textures are CC0 (more permissive, no conflict), and the Mixamo character and animations are neither included here nor covered; see the next section. Every dependency is permissively licensed (MIT, Apache-2.0, BSD-3-Clause, ISC) and therefore compatible — Apache-2.0 notably requires v3, as it cannot be combined with GPLv2.


Third-party assets

Two sources, with opposite consequences for this repository.

Textures — ambientCG, CC0, included here

The 36 voxel texture packs under assets/textures/ come from ambientCG and are released under CC0 1.0 — a public domain dedication. That permits use, modification and redistribution for any purpose, commercially included, and requires no attribution. They are therefore committed to this repository; see assets/textures/ATTRIBUTION.md for the credit ambientCG does not demand but has earned.

Adding a pack is a matter of unzipping it into assets/textures/ — the server scans that directory at startup and every folder becomes a palette entry, no code change (discoverTexturePalette). The naming ambientCG uses is what the scan keys on:

File in the pack folder Used as
…_Color.png (or _BaseColor / _Albedo / _Diffuse) base colour map
…_NormalGL.png (falls back to _Normal, then _NormalDX) normal map — GL, not DX
…_Roughness.png roughness map
…_AmbientOcclusion.png (or _AO) AO map, optional
<Pack>.png (the one without a map suffix) thumbnail in the texture picker

A single flat <name>.png directly in assets/textures/ also works, as a colour-only entry.

Character and animations — Mixamo, not included

The player character and its animations come from Adobe Mixamo. Their licence allows using them in a project but not redistributing the asset files, so they are not in this repository (.gitignore keeps them out) and you have to download them yourself. A free Adobe account is enough; there is no charge.

Without them the game still starts, but no player models appear.

What to get

Mixamo item Goes to
Character Swat assets/Shooter Pack/Swat.fbx
Rifle 8-Way Locomotion Pack (49 clips) assets/Rifle 8-Way Locomotion Pack/*.fbx

Download the animations as FBX Binary, one file per clip, and keep Mixamo's own file names — the conversion below derives each output name from them (walk forward left.fbxwalk_forward_left.glb), and the client asks for those exact names.

The resulting layout:

assets/
├── Shooter Pack/
│   └── Swat.fbx                     # character mesh + skeleton
└── Rifle 8-Way Locomotion Pack/
    ├── walk forward left.fbx        # 49 animation clips
    └── …

Only Swat.fbx is used from Shooter Pack/; any other clips you happen to have in that folder are unused. Every animation the game plays comes from the Rifle pack.

Then run the setup once

tools/setup-assets.sh          # --force reconverts clips that already exist

It refuses with the download instructions if a pack is missing, and otherwise produces three things:

Output Made from Why it exists
assets/anims/*.glb (49) the Rifle pack's FBX clips, via fbx2gltf three.js's FBXLoader cannot parse several of the source files; GLB loads reliably
shared/assets/skeleton.json Swat.fbx + those GLBs bone, hit-capsule and baked per-frame clip data; the server reads it off disk for per-bone hit detection (that path is not under the web root)
assets/packed/*.wga (51) all of the above what the web server actually serves — see below

All three are generated, not committed: they are the same Mixamo data in a different container, so the same licence applies. .gitignore keeps them out; the script rebuilds them from your download.

Without skeleton.json the server still runs but falls back to cylinder hitboxes (it says so at startup). Without assets/packed/ no character or animation appears at all.

The script wraps tools/convert-anims, client/scripts/extract-skeleton.mjs and deno task pack-assets; each can still be run on its own. Re-running it is safe — existing clips are skipped unless you pass --force, and skeleton.json comes out byte-identical.

Why the assets are served packed

Handing out Swat.fbx and the clip GLBs as plain files means any visitor can download a directly reusable copy of a licensed asset — that is redistribution rather than use. Game engines normally avoid this by converting third-party assets into their own container on import, and shared/assets/assetCrypt.ts does the same here: an AES-GCM container ("WGA1" | iv | ciphertext) that the client unwraps in memory and hands to the loader's parse().

skeleton.json is packed along with them, and for the same reason rather than for the hit capsules: it bakes all 19 clips out to per-frame bone transforms, so shipping it as plain JSON would hand out the animation data in another representation. The client fetches skeleton.wga for its F2 hitbox view; the server keeps reading the plaintext copy from shared/assets/, outside the web root.

This is not security, and is not meant to be. The key sits in the client bundle because the browser has to decrypt in order to render; anyone determined reads it out in minutes. What it buys is that the bytes on the wire are not a usable FBX/GLB.

Three places keep the plaintext off the wire, and they have to stay in sync: .gitignore, the --excludes in v-server/deploy.sh, and the 404 rule in client/vite.config.ts that makes the dev server behave like production.


Quick start

# Once: generate a dev certificate (see "TLS" below — 14 days validity, max!)
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
  -keyout certs/key.pem -out certs/cert.pem -days 14 -nodes \
  -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

cd server && npm install && npm start     # game server on UDP 4433
cd client && npm install && npm run dev   # Vite dev server on http://localhost:3000

Open http://localhost:3000 — the client fetches fingerprint.json (written by the server at startup) and pins the self-signed certificate via serverCertificateHashes.

Task Command
Tests (shared/) deno test --allow-read shared/
Bake a voxel map deno task compile-map <name>
Rebuild character assets tools/setup-assets.sh
Deploy v-server/deploy.sh

Architecture overview

┌─────────────────────────────────────────────┐
│              Browser / Electron             │
│  Three.js rendering, pointer lock, HUD      │
│  Client-side prediction + reconciliation    │
│  Snapshot interpolation for remote players  │
│  Voxel editor (raycast targeting, chunks)   │
│  ┌─────────────────────────────────────┐    │
│  │       Shared game logic (TS)        │    │
│  │  movement, physics, entities, RPC,  │    │
│  │  voxel meshing, serialization       │    │
│  └─────────────────────────────────────┘    │
│         WebTransport (HTTP/3 / QUIC)        │
└──────────────────┬──────────────────────────┘
                   │  datagrams (state, input, unreliable RPC)
                   │  streams   (join, reliable RPC: chat, voxel edits)
┌──────────────────▼──────────────────────────┐
│              Node.js server                 │
│  ┌─────────────────────────────────────┐    │
│  │       Shared game logic (TS)        │    │
│  └─────────────────────────────────────┘    │
│  Authoritative state, tick loop (100 Hz)    │
│  Snapshot history (lag comp + rollback)     │
│  Voice SFU (WebRTC relay)                   │
└─────────────────────────────────────────────┘

Tech stack

Area Technology Why
Rendering Three.js Most capable WebGL framework, large community
Networking WebTransport (HTTP/3 / QUIC) Low latency, unreliable datagrams + reliable streams
Server Node.js 22 + @fails-components/webtransport The only workable server-side WebTransport implementation
Language TypeScript One codebase for client and server
Physics/collision Rapier.js (WASM) Deterministic, identical on both sides
Client build Vite Fast dev server, production bundle
Server build esbuild A bundle is required — Node strips types but cannot handle legacy decorators
Tests deno test (shared/) Runs .ts natively, no build step
Desktop Electron (Forge + Builder) Thin shell around the live client

Why WebTransport instead of WebSocket?

  • QUIC: UDP-based, no head-of-line blocking like TCP
  • Unreliable datagrams: a lost position update does not matter — the next one is better anyway
  • Reliable streams: for everything that must arrive (chat, voxel edits, join/welcome)

Why Node.js (and not Deno)?

The server originally ran on Deno. Its WebTransport support did not hold up for QUIC servers, hence the move to Node.js with @fails-components/webtransport (quiche-based). Deno is still used for the tests in shared/ and the offline map compiler, because it runs .ts without a build step.

The server is bundled (npm run build → esbuild) because Node's built-in type stripping does not support the legacy decorators in use (@networked, @sync, @rpc).


Code-sharing strategy

shared/ runs identically on client and server:

shared/
├── game/
│   ├── Entity.ts / Entity3d.ts   # base classes (id/typeId, pos/rot)
│   ├── Pawn.ts                   # player body: movement, jump, crouch, weapon RPCs
│   ├── PlayerState.ts            # persistent identity: name, team, kills/deaths, pawnId
│   ├── VoxelVolume.ts            # editable voxel field as an entity (edit RPCs, colliders)
│   ├── StaticMesh.ts             # static level geometry as an entity (asset reference)
│   ├── Elevator.ts               # moving platform
│   ├── physics.ts                # Rapier wrapper (capsule sweep, raycast, colliders)
│   ├── skeleton.ts               # posed bone capsules for hit detection
│   ├── recoil.ts                 # recoil state machine (deterministic)
│   ├── voxelEdit.ts              # edit wire structs + deterministic application
│   └── World.ts                  # world state + raycast entry point
├── physics/movement.ts           # movement constants (speed, acceleration, gravity)
├── map/
│   ├── voxel.ts                  # VoxelGrid, greedy mesher, collider merge, RLE
│   ├── glbParser.ts              # GLB → vertex/index buffers (no Three.js)
│   └── glbWriter.ts              # buffers → GLB (offline bake)
├── net/
│   ├── decorators.ts             # @networked / @sync / @clientOnly / @serverOnly
│   ├── rpc.ts                    # @rpc — remote method calls on entities
│   ├── serialization.ts          # binary format, delta encoder
│   ├── messages.ts               # message types + input flags
│   ├── Snapshot.ts               # snapshot + ring buffer (client and server)
│   └── netSim.ts                 # artificial latency/packet loss for testing
├── materials/surfaces.ts         # friction, restitution, sound/decal keys per surface
└── math/                         # vec3, quaternions

Entity schema system

What goes over the wire is declared on the field itself. Serialization, delta compression and interpolation hints are generated from that — no hand-written network code per entity.

@networked()
export class Pawn extends Entity3d {
  @sync({ type: 'vec3F32', interpolate: true }) pos = { x: 0, y: 0, z: 0 }
  @sync({ type: 'quaternionF16' })              rot = { x: 0, y: 0, z: 0, w: 1 }
  @sync({ type: 'uint8' })                      energy = 100
  @sync({ type: 'bool' })                       crouching = false

  @serverOnly ownerId = 0        // server-side only, never on the wire
  @clientOnly recoil =          // client-side only
  private inputFlags = 0         // no decorator → never replicated
}

Decorator options

Option Values Meaning
type float32 float16 int16 int32 uint8 uint16 bool string bytes vec3F32 quaternionF16 Wire format. string (u8 length prefix) and bytes (u32) are variable length; the compound types expand into several wire slots
interpolate true / false Client smooths this field between snapshots
onlyTo 'all' / 'owner' 'owner': only the owning client receives the value
quantize number Reduce precision before sending

@serverOnly / @clientOnly mark fields that never reach the wire — the former additionally means "the client must not adopt this from a snapshot".

Bit positions and typeId

The order of the @sync decorators determines the bit position in the changedFields mask. Since shared/ runs identically on both sides, that ordering is automatically in sync.

Important: @networked() assigns the typeId in import order. Server (server/main.ts) and client (client/src/GameClient.ts) must therefore import the entity classes in the same order — currently Pawn=1, Elevator=2, StaticMesh=3, VoxelVolume=4, PlayerState=5. If the order diverges, the client decodes snapshots with the wrong schema (visible as [entity] unknown typeId=…).

Technical prerequisite

TypeScript legacy decorators (experimentalDecorators: true). They run at class-definition time — no per-instance overhead, no reflect-metadata.


RPC system

Besides the snapshot stream there are entity-bound remote calls. An @rpc declares where the method body runs — calling it on the other side only ships the arguments:

class VoxelVolume extends Entity3d {
  @rpc({ to: 'server', reliable: true, args: EditArgs })
  requestEdit(a: EditArgs, sender?: number): void {   // body runs on the SERVER
    if (!this.canEdit(sender)) return
    a.applyAtTick = getSimTick() + EDIT_DELAY
    this.commitEdit(a)                                 // → out to every client
  }

  @rpc({ to: 'allClients', reliable: true, args: EditArgs })
  commitEdit(a: EditArgs): void { this.scheduled.push(a) }
}
Option Values Meaning
to 'server' 'owner' 'allClients' Where the body executes
reliable true / false Reliable stream vs. datagram
args @sync-annotated class Argument struct; supplies the wire schema

sender is the authenticated playerStateId of the caller — never a client-supplied value. Calls that could mutate someone else's entity check sender !== this.id (see requestRename, requestChat, requestStateRate).

Used for: chat, rename, map switch/create/delete, voxel edits + baseline, hit feedback, reload and dry-fire sounds, respawn, round end, update rate.


Network model

Modelled on how Counter-Strike does it (the Source engine's netcode): server authority, client-side prediction, lag compensation. Deviations: WebTransport instead of a proprietary UDP stack, and a custom binary format in TypeScript.

Reliable vs. unreliable

Message Channel Why
Input (WASD, mouse, shots) Datagram Stale inputs are worthless; redundancy instead of retransmits
State (snapshot deltas) Datagram The next snapshot supersedes a lost one
Join / Welcome Stream Must arrive, exactly once
Voxel edits, chat, map switch Stream Ordering and delivery guaranteed
Hit feedback, dry fire Datagram Purely cosmetic

Client → server (input)

One input per tick, labelled with the tick it should take effect on (applyAtTick). Every packet carries the last n inputs (inputRedundancy, default 3), so a lost packet is healed by the next one without a retransmit. A list of the most recently received snapshot ticks rides along; the server picks its delta base from it.

An input that arrives late is rescheduled to the next tick rather than dropped (the player would otherwise visibly stall); one from far in the future is rejected (inputAcceptWindowFutureTicks).

Server → client (state)

StateMessage {
  tick                  # server tick of this snapshot
  baseTick              # snapshot the delta was built against (0 = full state)
  lastAppliedInputTick  # newest input of this client the server has applied
  entityDeltas[]        # changed fields only, plus removals
}

lastAppliedInputTick anchors reconciliation: only inputs after it are re-simulated.

How often a client receives state packets is decided by the client — via stateEveryNTicks in the join message, and afterwards at any time via RPC (the "Update rate" setting in the settings menu). Fewer packets save bandwidth at the cost of interpolation quality.

Delta compression

The server delta-compresses against a snapshot it knows the client holds (acknowledged through the snapshot ticks the client sends). Unchanged entities are absent from the delta, new ones are sent in full, and removed ones as an id plus a removed flag.

Interpolation & extrapolation

The client deliberately renders in the past (interpDelayTicks, default 15 at 100 Hz ≈ 150 ms) and interpolates remote players between two snapshots. The server clock is estimated from ping/pong samples and slewed rather than snapped, so the interpolation point never visibly jumps. The local player is never interpolated — that is what prediction is for.

Client-side prediction & reconciliation

  1. The client simulates its own pawn in a fixed-tick loop: exactly one applyInput per sim tick, in the same order as the server loop. If no fresh input exists for a tick, the last one is repeated — exactly as the server does.
  2. Every simulated tick is recorded in a history along with its resulting state.
  3. When a snapshot arrives, the server position is compared against the local history entry for the same tick. Only a deviation beyond 5 cm triggers a correction.
  4. Correcting means: snap to the server state, re-simulate all still-unacknowledged inputs, and decay the visual offset via correctionOffset instead of jumping.

Calling applyInput per tick rather than per sent input became mandatory with movement acceleration (MOVE_ACCEL): the call integrates velocity and is therefore no longer idempotent. At a frame-bound call rate the client would follow a different acceleration curve than the server — visible as a persistent offset when starting and stopping.

Lag compensation

A shot carries the two snapshot ticks and the interpolation factor the client was rendering at the moment of the trigger pull. The server reconstructs exactly that view, evaluates the hit against it, and caps the rewind at shotAcceptWindowTicks.

Shots deliberately mature for a few ticks (shotRoundtripBufferTicks) so slightly late packets are still evaluated in the right order. If an input arrives after its tick was already simulated, the server rolls back to the snapshot before it and re-simulates forward.


Movement & player state

Constants live in shared/physics/movement.ts and are applied in Pawn.applyInput / Pawn.onTick:

Quantity Value
Run speed 5.5 m/s (crouched 2.6 / airborne 3.0)
Acceleration full speed in 0.3 s (start, stop and direction changes)
Jump / gravity 6.5 m/s up at −20 m/s²
Eye height 1.59 m (crouched 1.10 m)

Ducking is immediate; standing up is gated by a headroom check, so the player stays crouched under an overhang. The capsule sweep runs through Rapier — identical on both sides, so prediction and server cannot drift apart.

On death the camera stays in the player's own perspective for 3 seconds (death cam); only then does the spectator take over (click = next living player). The corpse is simulated client-side as a ragdoll.


Hit detection: skeletal hitboxes

Hits are not tested against a single capsule around the player but against one capsule per bone. The server reconstructs the pose the shooter saw: animation index, clip start tick and crossfade state are all @synced, so the same pose the client rendered can be sampled again.

The bone data lives in shared/assets/skeleton.json and is extracted from the animation GLBs (regenerated by tools/setup-assets.sh). F2 draws the hitboxes in the client.


Maps

Two source formats end up as the same runtime files:

assets/maps/<name>/
├── <name>.voxel.json   # voxel source (in-game editor) — optional
├── <name>_col.glb      # collision geometry (server + client)
├── <name>_vis.glb      # visual geometry    (client only)
└── <name>.json         # entities: spawns, props, and the level itself as a StaticMesh
  • Voxel maps: the source is <name>.voxel.json (cell → texture name). The server loads it directly and keeps it as a VoxelVolume, which is what makes the map live-editable. deno task compile-map <name> bakes the three files above from it (greedy mesh per texture, box-merged cuboid colliders).
  • Blender maps: _col.glb / _vis.glb / .json come out of the export addon in tools/blender_addon/ (collections Collision/, Visual/, Entities/).

Collision GLB

Contains geometry and material names — no textures, no shaders. Client and server parse it with the same lightweight GLB parser (shared/map/glbParser.ts, no Three.js) and hand the buffers to Rapier. The material name selects the entry in shared/materials/surfaces.ts (friction, restitution, footstep/impact sound, decal).

Entities JSON

{
  "entities": [
    { "type": "spawn_ct", "position": [1.5, 0.6, 1.5], "yaw": 45 },
    { "type": "spawn_t",  "position": [4.5, 0.6, 4.5], "yaw": 225 },
    { "type": "StaticMesh", "asset": "maps/voxeltest", "position": [0, 0, 0], "yaw": 0 }
  ]
}

Voxel editor

B toggles build mode. There is no separate editor and no offline editing: building happens in the normal game client against a server (solo = your own local server), collaboratively and server-authoritatively.

Model

Everything smart about voxels lives once in shared/map/voxel.ts (occupancy, face culling, greedy meshing, box merge) and has two consumers: the live VoxelVolume entity and the offline bake in tools/compile-map.ts — build in-game, then bake the result into a static .glb level.

// assets/maps/<name>/<name>.voxel.json — the editor source
{
  "version": 1,
  "baseVoxelSize": 0.25,                                 // edge length of a base cell in metres
  "palette": [{ "name": "stone", "texture": "textures/stone" }],
  "blocks": { "12,0,-8": 0, "13,0,-8": 0 },              // "x,y,z" (integer cells) → paletteIndex
  "entities": [{ "type": "spawn_ct", "position": [3, 0, 0], "yaw": 0 }]
}

Coordinates are integer base cells (world = coord * baseVoxelSize); block size is a brush (2ⁿ cells at once), not a property of a block, so there is no overlap logic — placing overwrites the cells it covers.

Asset references (StaticMesh.asset, palette[].texture) are paths under assets/, resolved by the convention folder name = basename: "props/crate"assets/props/crate/crate_{vis,col}.glb. The path is the lookup, so there is no name→path manifest; the server normalises it and confines it to the assets root. The client preloads everything a map references before the round starts — scanning the baked entities list, which therefore acts as the manifest. Lazy loading on spawn would be wrong: prediction would collide against geometry that has not arrived yet.

Edit flow

An action key sends requestEdit (reliable RPC). The server validates it, stamps an applyAtTick a few ticks ahead and broadcasts commitEdit to everyone — every side then applies the edit deterministically on the same tick, which is what keeps the player's own movement prediction from diverging when someone edits the block they are standing on. Edits themselves have no prediction: at 50–100 ms delay that is invisible while building and it saves the entire rollback machinery. Late joiners get the grid once as an RLE baseline (sendBaseline, an owner RPC).

Controls

A slot holds what is placed (a palette texture or a team spawn); the key decides the action. Everything except the slot keys is rebindable.

Key Action
19, 0 Select slot; hold = eyedropper (adopt the aimed-at block's texture and size)
M Picker (textures / spawn points) — hover an entry, press a number to assign the slot
Q / E / R Place / delete / replace (texture only — R never adds or removes cells)
G Flood replace: the aimed-at block plus every laterally adjacent block of the same texture
Y (Z) or left click Anchor/discard an area, locked to the plane of the clicked face
X or right click Turn the area into a 3D box (the crosshair then drives depth)
Wheel Brush size (texture slot) or spawn rotation (spawn slot); Alt+wheel = aimed-at texture size
, / . Texture size of the active slot
F / N / H / Alt+S Fly / no-clip / editor help / save

With an area or box spanned, the action key still decides what happens, applied to the cell layer at the plane relative to where the camera is now: Q builds on the near side, E deletes the layer behind it, R replaces there. Flood and area fills are capped (MAX_FLOOD_CELLS / MAX_FILL_CELLS, 16384 each); beyond that the client aborts instead of sending a truncated region. On the wire all of it is a single EditArgs (op, cell, brush size, axis spans ex/ey/ez).

Chunked rebuild

Geometry is built per chunk (16³ cells). An edit marks only the chunks it touched — including the neighbouring chunk when it sits on a border, whose boundary faces change too — and rebuilds just their mesh and colliders. Colliders are rebuilt immediately, meshes are spread across frames by a budget, so a baseline or a large area fill does not freeze a frame. The cost of an edit therefore scales with the edit, not with the map (≈5 ms instead of ≈4.5 s per block on a 60k-cell map). To make that possible, VoxelGrid stores sparsely per chunk and densely within one.

Bake

deno task compile-map <name> reads the voxel source and writes _vis.glb (culled greedy geometry, one material per texture), _col.glb (merged cuboids with collider_type) and <name>.json (the source entities plus the level itself as a StaticMesh). The output is format-identical to the Blender pipeline, so nothing in the loading path changes.

The bake still writes debug colours (a stable golden-angle hue per palette index) instead of binding the real textures — a baked map looks flat next to the editable one. Multi-material is in place; a texture atlas would be the optional optimisation on top.


Server configuration

server/config.ts — precedence: CLI flag > --config <file.json> > default.

Option Default Meaning
port / host 4433 / :: UDP bind. :: is dual-stack; 0.0.0.0 would be IPv4-only and would reject every client that resolves the AAAA record
tickRate 100 Simulation steps per second; drives dt, lag-comp granularity, snapshot rate
maxPlayers 10
maxAllowedLatencyMs 500 Upper bound for rewinding
disconnectTimeoutMs 10000 No packet for this long → player considered gone
interpDelayTicks 15 Interpolation buffer handed to the client
inputFutureOffsetTicks 20 How far ahead clients label their inputs
inputAcceptWindowPastTicks / …FutureTicks 0 / 50 Acceptance window for inputs
shotDamage 25
shotAcceptWindowTicks 20 Maximum rewind window for shots
shotRoundtripBufferTicks 10 Maturation delay before a shot is evaluated
map / mapDir / assetsDir voxeltest / ./assets/maps / ./assets
tls.cert / tls.key ./certs/…

Client-side overrides go through URL parameters: ?server=, ?name=, ?interpDelayTicks=, ?stateEveryN=, ?inputRedundancy=, ?rawSnapshot=1, ?serverGhost=1, plus ?simDropEveryN=&simDropBurst=&simDelayMin=&simDelayMax= for artificial network conditions.

TLS

WebTransport requires HTTPS, even locally. Development uses a self-signed certificate (see quick start). Two pitfalls:

  • At most 14 days validity, and ECDSA P-256 — otherwise browsers reject pinning via serverCertificateHashes. An expired dev certificate shows up as "Opening handshake failed".
  • At startup the server writes client/public/fingerprint.json with the fingerprint; the client reads it and pins. In production (CA-signed certificate) that file must not exist — when it is missing the client correctly skips pinning. v-server/deploy.sh therefore deletes it before every production build.

Match flow

Two teams (CT / T), balanced automatically on join. When one team is wiped the round ends, all clients get a roundEnd banner, and after 3 seconds everyone respawns at their team spawns. Corpses stay in place until then so the death animation can play out. Kills and deaths are tracked on the PlayerState and shown on the scoreboard (Tab).

A player in build mode is invulnerable (buildMode, enforced server-side).


Client controls & UI

Key Function
WASD / Space / Shift or C Move / jump / crouch
Left mouse / R Fire / reload
Enter Chat
V (hold) Voice push-to-talk
Tab Scoreboard
F1 or H Help overlay
F2 Hitbox debug
B Build mode (there: H = editor help, M = texture palette)
Escape Pause menu

Every action is rebindable (client/src/keybindings.ts): keys, chords, mouse buttons and the wheel, up to two bindings per action; only the hotbar slots and Escape are fixed. Mouse sensitivity lives there too.

The settings menu additionally offers: name, language (EN/DE, client/src/i18n.ts — new strings need both), render resolution (internal upscale for weak GPUs), fullscreen, sound and voice volume, voice chat on/off, and the network update rate.

Two UI surfaces outside the bundle

The download page and the Electron shell's offline error page cannot import i18n.ts, and both still follow the language setting. They reach it differently, because they fail differently:

Surface Strings from Language from If that fails
/download/ (v-server/download/index.html) fetches /i18n.json, a snapshot of the table emitted into the client bundle by vite.config.ts the same origin's localStorage, or the ?lang= the game appends to the link stays English — every string is also inline in the HTML
Electron error page (electron/src/main.ts) pushed by the client over the preload bridge (setUiStrings) and cached in userData implicit: the client sends the active language's strings English defaults in the shell

The Electron path has to work when the game page did not load, so it cannot ask at the moment it needs an answer — it uses what the last successful session left behind. That also means an installed app older than the client it loads simply keeps its English page; the bridge call is optional-chained on both ends.

Those strings cross from a remote page into shell-rendered HTML, so the main process treats them as untrusted: shape and length are checked on arrival, and both are HTML-escaped at render time.


Text chat

Chat is not a message type but two RPCs on PlayerState — the same shape as requestRename. PlayerState is the right carrier: exactly one per player, it survives rounds and respawns (unlike the Pawn), and it already @syncs nickname and team.

Client A types "gg" ──requestChat(text)──►  server: sender === this.id? trim, cap, rate-limit
                                            stamps name + team from the session
      A, B, C  ◄──broadcastChat(speakerId, name, team, text)──  to: 'allClients'
                                            → ClientEffects.onChat → ChatOverlay

The server stamps name and team into the broadcast rather than letting clients resolve speakerId locally — that is robust against rename races and against a sender whose PlayerState has not reached a client yet. Spoofing is impossible: the identity comes from the authenticated sender, never from the payload. server/main.ts needed no changes for any of this, which is the point of the RPC approach.

Topic Decision
Length / rate 200 characters (hard-capped server-side), ≥ 500 ms between lines; excess dropped silently
Whitespace \s+ collapsed, empty messages dropped
XSS Display via textContent only — never innerHTML, no Markdown, no HTML
History None. A pure client overlay; nothing stored, nothing replayed for late joiners

While the input line is open, BrowserInput gates its keydown/keyup/mousemove handlers on a chatCapturing flag and returns immediately — pointer lock stays, so the round does not pause and the camera merely freezes. Escape in the field cancels chat and stopPropagation()s so the global Escape handler does not also open the pause menu.

Not built (each a small addition): team chat (a scope field plus per-team routing, which the server already has), slash commands, kill-feed lines through broadcastChat with speakerId = 0, and client-side mute (speakerId is carried for exactly that).


Voice chat

The server is a WebRTC SFU: every client opens one WebRTC connection to it, and the server forwards the Opus RTP packets to the other peers without decoding them — little CPU on the server, while per-player mute and (later) spatialisation stay possible on the client. Uplink is one audio track per client, downlink one track per speaker.

Firefox/Chromium                                  Node server (werift SFU)
getUserMedia (Opus, AEC/NS/AGC)
  └─ RTCPeerConnection ──DTLS-SRTP/UDP──► RTCPeerConnection (per session)
       ▲ 1 track up, 1 per speaker down     onTrack → forward RTP to the other sessions' senders
       └─ WebAudio: source → GainNode → destination
   signalling (SDP/ICE) rides the EXISTING reliable WebTransport connection
  • No TURN. One endpoint — the server — has a public IP and an open UDP port, and every client connects outward to it anyway; ICE always succeeds over the server's host candidate.
  • No home-grown audio. getUserMedia plus WebRTC bring echo cancellation, noise suppression, jitter buffer and packet-loss concealment in every browser. This is what made Firefox work; the previous WebCodecs/MediaStreamTrackProcessor implementation was Chromium-only and is gone.
  • Library: werift — pure TypeScript, no native build, mirrors the browser API and gives direct RTP access. It is essentially a one-person project, so it carries maturity risk; mediasoup is the fallback if load or renegotiation bugs demand it.
  • Signalling is MessageType.VoiceSignal (reliable, both directions), a JSON tagged union of offer / answer / candidate. Deliberately not an @rpc: the body is not entity behaviour — it drives the session's peer connection, which lives in server/main.ts's session context.
  • Push-to-talk flips micTrack.enabled instead of tearing the connection down; Opus DTX keeps silence cheap. The speaking indicator is a client-side AnalyserNode (RMS), no server signal.
  • Renegotiation happens only when someone connects or disconnects (not on respawn), which is what keeps the most error-prone part rare.

Ports: WebRTC media is UDP straight to the Node process — nginx is not involved. The default range is 40000–40100 (VOICE_PORT_RANGE="min-max"), and it must be open in the firewall and any cloud security group, otherwise ICE fails silently. The public IP is auto-detected at startup and announced as a host candidate (only needed behind cloud NAT); override with VOICE_PUBLIC_IP, disable with VOICE_DISABLE_IP_AUTODETECT=1. werift generates its own DTLS certificate per peer connection — unrelated to the WebTransport fingerprint certificate.

Privacy: opt-in, off by default, a visible mic indicator, track.stop() on disable, no server-side recording.


Audio

All game sounds are procedural, generated through the Web Audio API — there are no audio assets. Shots, impacts, reloads and footsteps are built from noise plus filters; footsteps are distance-driven (one step per distance travelled, with loudness and timbre following speed and the surface from SURFACES). Positional sounds go through THREE.PositionalAudio, and the master volume through the mixer in the settings.


Bots & test harness

There is no playing bot AI in the server (botFillThreshold is configurable but unimplemented). What exists instead is a Node client that runs the very same GameClient as the browser, just without rendering. That makes scenarios scriptable: movement, duels, network faults, divergence measurements.

const bot = await createBot({ nickname: 'Alpha', input, tickIntervalMs: 16 })

Ready-made scenarios live in client/scenarios/ (among them duel.ts, step_divergence.ts, accel_divergence.ts, death_cam.ts, state_rate.ts, wt_probe.ts).

Careful: npm run scenario (tsx) is currently broken — .js and .ts import specifiers load shared/net/decorators.ts as two separate module instances, which leaves the typeId registry in the client empty (unknown typeId, skipping) so the bot never spawns. Until that is fixed, bundle the scenario with esbuild (which dedupes by path) and run it from client/dist-node/:

npx esbuild scenarios/<x>.ts --bundle --platform=node --format=esm \
  --packages=external --tsconfig=tsconfig.node.json --outfile=dist-node/<x>.mjs
node dist-node/<x>.mjs

Input recording & replay

The inputs of a browser session can be recorded and played back later — in the browser or through the Node client — which is what makes bugs reproducible.

Every session is recorded from the first tick onwards; "Export Recording" saves it as JSON:

{ "version": 1, "frames": [ { "mask": 8, "yaw": 1.57, "pitch": 0.0, "fire": 0, "jump": 0 } ] }

mask is the bitmask of held movement keys (Flags in shared/net/messages.ts), fire/jump are monotonically increasing counters. "Load Recording" plays a recording back in the browser (ReplayInput takes over the input channel and switches back to live input at the end); in the Node client scenarios/replay.ts does the same.

File Description
client/src/RecordingInput.ts Transparent InputSource proxy that records every tick
client/src/ReplayInput.ts InputSource that plays a recording back
client/src/InputProxy.ts Runtime switch between live and replay input
client/scenarios/replay.ts Ready-made scenario for the Node client

Deployment

Production runs on a Debian VPS, alongside unrelated services (nginx, Nextcloud AIO, Rustdesk) that the setup leaves untouched. Which machine and which hostname is not in this repository — it lives in v-server/deploy.conf (gitignored, template at v-server/deploy.conf.example), which is the single place naming the production server.

  • nginx serves the static Vite build (/opt/webgame/client-dist) and the assets under /assets/; TLS via certbot/Let's Encrypt.
  • The game server runs as a systemd unit and binds UDP 4433 directly, terminating TLS itself — WebTransport/QUIC sessions cannot be proxied through nginx. Only the static files go through it.
  • Desktop app: electron/ is a thin shell that loads the client live from the URL (no bundled client, so no auto-update mechanism is needed). Installers are published under /download/.

Configuring the target

Nothing in the repository names the production machine. Two gitignored files supply it:

File Used by Contents
v-server/deploy.conf deploy.sh, remote-setup.sh (→ setup.sh) SSH_HOST, DOMAIN, REMOTE
electron/game-url.txt the Electron build one line, the URL the desktop app opens
cp v-server/deploy.conf.example v-server/deploy.conf   # then edit
echo 'https://game.example.com/' > electron/game-url.txt

The scripts refuse to run with a clear message if deploy.conf is missing or incomplete, rather than deploying somewhere unintended. nginx-server.conf and deploy-hook.sh are templates: setup.sh substitutes __DOMAIN__ / __REMOTE__ while installing them on the server, so the hostname exists only on the machine itself.

For the desktop app the resolution order is WEBGAME_URL (handy for pointing a dev build at http://localhost:3000), then game-url.txt — copied into dist/ by npm run build and shipped inside the package — then http://localhost:3000/, so a fresh clone runs against a local server instead of failing.

Why it is set up this way

  • No Docker for the game server: UDP/QUIC would take needless overhead through Docker NAT, and --network host throws away the isolation that would justify it. The native binding (webtransport.node, via prebuild-install) only needs npm ci on the target platform.
  • Its own subdomain, not a sub-path: root-absolute fetches in the client (/fingerprint.json, /assets/...) then resolve correctly with no base/BASE_URL rework. It needs its own Let's Encrypt lineage, used by both nginx (443) and the Node process (UDP 4433).
  • A dedicated webgame user under /opt/webgame, not root — an internet-facing service with a native C++ binding should not run privileged on a machine that also holds Nextcloud data. Two consequences: Node must be installed system-wide (root's nvm Node is unreachable, /root is drwx------), and the certificate needs a certbot deploy hook that copies it somewhere webgame can read — /etc/letsencrypt/live is root-only and certbot resets its permissions on every renewal, so chmod is not an option.
  • No firewall change: the iptables INPUT policy is ACCEPT and the existing DROP rules only cover Docker's FORWARD chain, so a directly bound host port is reachable as soon as it is open.

v-server/

File Role
setup.sh One-off bootstrap on the VPS (idempotent): user + /opt/webgame, Node 22.x system-wide, nginx block, certificate + deploy hook, systemd unit (enable, deliberately no start — nothing is deployed yet)
remote-setup.sh Runs locally: scps the bootstrap files over and invokes setup.sh via ssh
nginx-server.conf The server block, symlinked into sites-enabled/; certbot --nginx adds the SSL directives
node-server.service / start.sh The systemd unit (running as webgame) and its ExecStart
deploy-hook.sh certbot deploy hook: copies renewed certs to /opt/webgame/certs/, restarts the service
deploy.sh The actual deploy (below)

/opt/webgame/ holds server/, client-dist/, assets/, shared/assets/, downloads/ and certs/ — everything but certs/ is filled by the deploy, not by setup.sh.

v-server/deploy.sh

  1. Build client (vite build) and server (esbuild bundle). client/public/fingerprint.json is deleted first: it is regenerated by every local server start, and Vite's public-dir copy would otherwise bake a stale dev hash into the production build — the client would then pin it via serverCertificateHashes and reject the real CA-signed certificate.
  2. rsync server/dist/ + manifest, client/dist/client-dist/, and assets/ — excluding /user-maps/, so maps created at runtime via New / Save-as survive --delete.
  3. rsync shared/assets/skeleton.json, which the server reads off disk for authoritative per-bone hit detection (separate from the copy inside the client bundle).
  4. Collect desktop installers into downloads/, write manifest.json (the landing page sniffs the visitor's OS from it) and publish the download page. It deliberately does not build them — run cd electron && npm run dist yourself.
  5. Remote npm ci --omit=dev (a fresh native binary for the target platform), chown, and systemctl restart webgame-server.service.

Operational notes

  • A certificate renewal must restart the server. It reads cert and key only at startup, so the deploy hook restarts it; without that it keeps serving the expired certificate, which looks exactly like a broken WebTransport handshake.
  • Bind dual-stack. The game's hostname has both an A and an AAAA record. With an IPv4-only bind (host: '0.0.0.0') every client that resolves the AAAA record sends its QUIC Initial to the v6 address, where nothing listens — the handshake times out while the website (nginx listens on both) keeps working. Hence the host: '::' default.
  • client/scenarios/wt_probe.ts checks connectivity from outside; it tells a timeout ("nothing listening") apart from a rejected certificate and can force the DNS resolution order.

Project layout

webgame/
├── shared/              # client + server (see "Code-sharing")
│   ├── game/  net/  map/  physics/  materials/  math/  assets/
├── client/
│   ├── index.html
│   ├── src/
│   │   ├── main.ts              # entry point, bootstrap, settings wiring
│   │   ├── GameClient.ts        # networking, prediction, reconciliation, snapshots
│   │   ├── ThreeRenderer.ts     # Three.js scene, player models, voxel chunks, viewmodel
│   │   ├── BrowserInput.ts      # pointer lock, keyboard/mouse, HUD interaction
│   │   ├── keybindings.ts       # rebindable actions + mouse sensitivity
│   │   ├── i18n.ts              # EN/DE
│   │   ├── SettingsMenu.ts  MapMenu.ts  ChatOverlay.ts  Scoreboard.ts  HelpOverlay.ts
│   │   ├── VoiceChat.ts  Ragdoll.ts  RecordingInput.ts  ReplayInput.ts  InputProxy.ts
│   │   ├── harness.ts           # Node bot (headless GameClient)
│   │   └── edit/                # voxel editor UI (controller, hotbar, palette)
│   └── scenarios/               # scriptable bot scenarios
├── server/
│   ├── main.ts                  # WebTransport listener, tick loop, rollback, shots, rounds
│   ├── config.ts                # configuration (CLI / JSON / defaults)
│   ├── mapLoader.ts             # map loading (GLB + voxel source)
│   └── voiceSfu.ts              # WebRTC relay for voice chat
├── electron/                    # desktop shell (Forge + Builder)
├── tools/
│   ├── compile-map.ts           # voxel source → _vis.glb / _col.glb / .json
│   ├── blender_addon/           # export addon (visual / collision / entities)
│   └── convert-anims/           # FBX → GLB for the animation clips
├── assets/                      # maps, textures, models, animations
├── v-server/                    # deploy scripts, nginx block, systemd unit
└── certs/                       # TLS (gitignored)

Tests

deno test --allow-read shared/     # serialization, RPC, voxel, recoil, GLB parser/writer

The focus is on shared/ — the code that has to be bit-identical on client and server. On top of that, the bot scenarios in client/scenarios/ run against a real server and measure deviations (prediction offset, update rates, death-cam timing).


Open points

  • Footsteps are too loud relative to gunshots.
  • Grenades.
  • Comments and documentation in this repository should be in English (this README is; the remaining source comments are not fully audited).
  • Fix npm run scenario (see the note under "Bots & test harness").
  • Server-side bot AI (botFillThreshold exists but has no implementation).
  • Multiple rooms / lobby: the server currently hosts exactly one map with one player list.
  • No origin check in the WebTransport server — any page can connect.
  • The bake writes debug colours instead of the real textures (see "Voxel editor").
  • Edit permissions: canEdit currently allows everyone; no roles, no rate limit.
  • Autosave: the server persists a volume only on an explicit save keypress (requestSave), so unsaved edits are lost on a restart. An automatic cadence (per edit vs. debounced) is undecided.
  • Per-face voxel textures (different top/side/bottom); one texture per block for now.
  • Baseline size for large volumes — RLE today, possibly chunk-wise transfer later.

About

Counter-Strike-style round-based multiplayer shooter that runs in the browser: authoritative netcode with prediction and lag compensation, skeletal hitboxes, and a voxel editor for building maps while the match is running.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages