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/).
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.
Two sources, with opposite consequences for this repository.
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.
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.
| 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.fbx →
walk_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.fbxis used fromShooter Pack/; any other clips you happen to have in that folder are unused. Every animation the game plays comes from the Rifle pack.
tools/setup-assets.sh # --force reconverts clips that already existIt 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.mjsanddeno task pack-assets; each can still be run on its own. Re-running it is safe — existing clips are skipped unless you pass--force, andskeleton.jsoncomes out byte-identical.
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.
# 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:3000Open 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 |
┌─────────────────────────────────────────────┐
│ 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) │
└─────────────────────────────────────────────┘
| 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 |
- 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)
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).
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
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
}| 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".
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=…).
TypeScript legacy decorators (experimentalDecorators: true). They run at class-definition
time — no per-instance overhead, no reflect-metadata.
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.
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.
| 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 |
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).
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.
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.
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.
- The client simulates its own pawn in a fixed-tick loop: exactly one
applyInputper 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. - Every simulated tick is recorded in a history along with its resulting state.
- 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.
- Correcting means: snap to the server state, re-simulate all still-unacknowledged inputs, and
decay the visual offset via
correctionOffsetinstead of jumping.
Calling
applyInputper 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.
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.
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.
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.
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 aVoxelVolume, 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/.jsoncome out of the export addon intools/blender_addon/(collectionsCollision/,Visual/,Entities/).
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": [
{ "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 }
]
}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.
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.
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.
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).
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 |
|---|---|
1–9, 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).
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.
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/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.
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.jsonwith 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.shtherefore deletes it before every production build.
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).
| 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.
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.
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).
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/
MediaStreamTrackProcessorimplementation 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 ofoffer/answer/candidate. Deliberately not an@rpc: the body is not entity behaviour — it drives the session's peer connection, which lives inserver/main.ts's session context. - Push-to-talk flips
micTrack.enabledinstead of tearing the connection down; Opus DTX keeps silence cheap. The speaking indicator is a client-sideAnalyserNode(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.
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.
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 —.jsand.tsimport specifiers loadshared/net/decorators.tsas 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 fromclient/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
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 |
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/.
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.txtThe 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.
- No Docker for the game server: UDP/QUIC would take needless overhead through Docker NAT, and
--network hostthrows away the isolation that would justify it. The native binding (webtransport.node, viaprebuild-install) only needsnpm cion the target platform. - Its own subdomain, not a sub-path: root-absolute fetches in the client (
/fingerprint.json,/assets/...) then resolve correctly with nobase/BASE_URLrework. It needs its own Let's Encrypt lineage, used by both nginx (443) and the Node process (UDP 4433). - A dedicated
webgameuser 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,/rootisdrwx------), and the certificate needs a certbot deploy hook that copies it somewherewebgamecan read —/etc/letsencrypt/liveis root-only and certbot resets its permissions on every renewal, sochmodis not an option. - No firewall change: the
iptables INPUTpolicy isACCEPTand the existingDROPrules only cover Docker'sFORWARDchain, so a directly bound host port is reachable as soon as it is open.
| 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.
- Build client (
vite build) and server (esbuild bundle).client/public/fingerprint.jsonis 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 viaserverCertificateHashesand reject the real CA-signed certificate. rsyncserver/dist/+ manifest,client/dist/→client-dist/, andassets/— excluding/user-maps/, so maps created at runtime via New / Save-as survive--delete.rsyncshared/assets/skeleton.json, which the server reads off disk for authoritative per-bone hit detection (separate from the copy inside the client bundle).- Collect desktop installers into
downloads/, writemanifest.json(the landing page sniffs the visitor's OS from it) and publish the download page. It deliberately does not build them — runcd electron && npm run distyourself. - Remote
npm ci --omit=dev(a fresh native binary for the target platform),chown, andsystemctl restart webgame-server.service.
- 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 thehost: '::'default. client/scenarios/wt_probe.tschecks connectivity from outside; it tells a timeout ("nothing listening") apart from a rejected certificate and can force the DNS resolution order.
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)
deno test --allow-read shared/ # serialization, RPC, voxel, recoil, GLB parser/writerThe 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).
- 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 (
botFillThresholdexists 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:
canEditcurrently 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.