A clean-room CS:GO-style surf game that runs in the browser. The movement and collision code is reimplemented from the publicly documented Source engine movement model — no Source engine code (leaked or licensed), no Valve maps, no Valve assets, and no copyrighted community maps are included.
- Live: https://communitypokeorg.github.io/cleanroom-surf/
- License: MIT
- Bundled map: one tiny original test course built from box/wedge primitives — playable out of the box.
| Input | Action |
|---|---|
| Click canvas | capture mouse |
| Mouse | look |
| W A S D | move / air strafe (on ladders: climb) |
| Ctrl or C | crouch / duck |
| Space | jump (autobhop off — must release between jumps); on a ladder: hop off |
| R | reset to current stage start (zero velocity) |
F3 or ` |
debug overlay: hull wireframes + trigger volumes |
| Drag & drop file | load .bsp / .bsp.bz2 / .vpk locally |
This is an honest list, not marketing.
Matches the documented Source movement model (numerically validated — see Movement validation below):
- 64-tick fixed timestep simulation (
tickRateconfigurable insrc/move/constants.ts, everything else scales offdt). - Documented
FullWalkMovetick order: duck → ladder check → StartGravity (half step) → CheckJumpButton → friction → accelerate/air-accelerate → move+clip → CategorizePosition → FinishGravity →sv_maxvelocityclamp. Jump runs before friction (no friction on the jump tick) and applies its own FinishGravity inside the jump — a jump tick eats three half-gravity steps total. - Ground friction
sv_friction 5.2,sv_stopspeed 80(control = max(speed, stopspeed),drop = control*friction*dt) — geometric decay above 80 u/s, linear −6.5 u/s per tick below. - Ground accelerate
sv_accelerate 5.5(addspeed/accelspeedformulation,m_surfaceFrictionfactor),sv_maxspeedcap 250 (the unarmed/knife move speed; the baresv_maxspeedcvar is 320). - Air accelerate
sv_airacceleratewith the 30 u/s wishspeed projection cap (sv_air_max_wishspeed) — the mechanism that makes strafing gain speed — withaccelspeedcomputed from the uncapped wishspeed and multiplied bym_surfaceFriction(documented behavior). - The CS:GO deadstrafe quirk:
m_surfaceFrictiondrops to0.25while airborne and0 < vel.z ≤ 140(NON_JUMP_VELOCITY), damping air accel during the early rise. - Gravity
sv_gravity 800applied as two half-steps per tick (semi-implicit Euler);sv_maxvelocity 3500per-component clamp. - Jump impulse
sv_jump_impulse301.993377 (= √(2·800·57)); ducked jumps setvel.zinstead of adding. Autobhop off by default. - Slide-on-ramp collision: ClipVelocity (
overbounce 1.0) with thenormal.z ≥ 0.7walkable threshold, multi-plane resolution (up to 5 clip planes, crease slide on two planes),MAX_BUMPS = 4iteration loop. StayOnGroundsnap-down (2 u up, stepSize down) so walking downhill keeps contact; step-up handling up to 18 units (documented StepMove).CategorizePosition: 2 u ground trace,vel.z > 140is unconditionally airborne.- Crouch/duck: instant hull swap 72→54 u tall (eye 64→46), ground duck pins feet, air duck pins hull top ("duck jump"), unduck blocked under low ceilings, ducked move speed ≈ 34 % of maxspeed (~85 u/s).
- Ladders:
func_ladder/CONTENTS_LADDERvolumes, documentedLadderMove— short hull trace toward the wish direction attaches, climb speed 200 u/s decomposed along the ladder plane,sv_ladder_dampensteep-incidence dampening, jump pops off at 270 u/s along the ladder normal. - Player AABB
32 × 32 × 72swept as a hull-expanded point trace against convex brush planes — real hull collision, not triangle soup, so no phantom floors or seam snags. DIST_EPSILON = 1/32contact gap (surfaces are not touched flush).
Approximate / simplified:
- Player origin is the center of the AABB (Source uses a feet origin); equivalent physics, different bookkeeping.
- Tuning follows common surf-server values (
sv_airaccelerate 150,sv_maxvelocity 3500). Exact feel differs per server config — cvars are exposed insrc/move/constants.ts. - Ground-bump corner cases (Source's special
onGroundvelocity preservation on slopes) use the simplified clip; very tight edge cases may differ slightly. - Displacement collision: each displacement is subdivided and each triangle becomes a thin prism brush (top plane + 3 edge planes + bottom cap). The triangle diagonal split follows the bilerp grid rather than the physics-triangulation lump (LUMP_DISP_TRIS). Works on typical surf displacements; extreme skew may differ at triangle seams.
- Trigger push:
pushdir * speedis applied as continuous acceleration while inside;basevelocityapplies once on entry. Source's exact momentum blending is not replicated. - No water physics, surfing-specific ground "boost" bug, or stamina. Water brushes are non-solid.
- BSP rendering uses placeholder flat colors derived from texture names — the pakfile lump and VPK textures are never read (they're copyrighted assets). No lightmaps — flat lambert lighting.
trigger_multiple-based start/end zones are detected by targetname heuristics (start|stage|zone|sN→ stage zone,end|goal|finish→ goal); maps that name zones differently won't activate stages. Stage resets always work:trigger_hurtand R-key resets return to the current stage start.- Ladders: climb-out over the top edge does not "mantle" onto the ledge the way the engine does — you detach once the hull clears the volume. Lateral board-control near the volume edge is approximate.
- BSP
func_brush/func_detailbrushes collide automatically (they're in the world brush list); entity logic beyond triggers (doors, buttons, rotating, outputs/inputs) is not simulated. - Only BSP version 20 is accepted (CS:GO-era). Other versions are rejected with a clear error.
Deliberately never going to be exact: frame-perfect equivalence with a 64-tick CS:GO server is not claimed. Movement constants and the collision algorithm are built to the documented model and validated by the test suite, not bit-compared against the engine.
test/validation.test.ts checks the model against analytical
expectations computed from the documented formulas — every expectation in
the file is derived by hand (or straight from the cvar values), never
curve-fit to the implementation. All tolerances are explicit in the test.
Sources (public documentation + independent community measurements):
ValveSoftware/source-sdk-2013— the published SDK tree (game/shared/gamemovement.cpp, read as behavioral documentation: tick order, friction/accelerate/air-accelerate formulas, ClipVelocity, StepMove/StayOnGround, CategorizePosition, Duck, LadderMove).- Valve Developer Community wiki —
Contents/CONTENTS_*flags,sv_jump_impulse(= √(2·g·57)), duck hull heights, step size. - CS:GO cvar reference (Liquipedia /
srcdsdumps):sv_gravity 800,sv_accelerate 5.5,sv_airaccelerate 12(surf 100–150),sv_friction 5.2,sv_stopspeed 80,sv_maxspeed 320cvar / 250 unarmed cap,sv_maxvelocity 3500,sv_ladder_dampen 0.2,sv_ladder_angle -0.707. - Community KZ/surf documentation (e.g. zer0k-z's CS:GO movement notes,
kz-rush): the 30 u/s air wishspeed cap, the deadstrafe
0.25surface-friction quirk, jump height ≈ 57 u continuous / ~54.7 u at 64 tick, ladder hop 270.
Validated numerically (23 tests, all passing):
| Behavior | Expectation | Result |
|---|---|---|
| Free fall, 64 ticks | drop = g·t²/2 = 400 u, vz = −800 | exact match |
| Friction > stopspeed | geometric s·(1−f·dt): 250→163.65 @5t, →107.13 @10t | match |
| Friction < stopspeed | linear −6.5 u/s/tick → 0 | match |
| Ground accel | recurrence s′=s·0.91875+min(21.48, 250−s′): 21.48/81.42/144.63/250 | match |
| Ducked accel | caps at ~85 (250·0.34) | match |
| Air strafe gain | +30 toward ⊥ wishdir → √(250²+30²) = 251.79 | match |
| Deadstrafe | aa12: gain 30 → 11.72 while 0<vz≤140 | match |
| Jump tick | vz_end = J−18.75 = 283.24 (triple half-gravity) | match |
| Ducked jump | vz = J overwrite → J−12.5 = 289.49 | match |
| Jump apex / airtime | apex 54.65 u @ tick 24, lands tick 47 (2 u snap) | match |
| Surf slide | v·n clipped to residual −g·dt/2·n_z, vx gain g·dt·n_x·n_z/t | match |
| Walkable threshold | n_z = 0.7 stands, 0.69 slides | match |
| Ledge / landing | walks off → airborne; lands at 36+1/32 gap | match |
| Duck hull | ground pins feet, air pins top, unduck blocked under 60 u ceiling | match |
| Ladders | attach, climb 200 u/s, hang, hop off at 270·n, top detach | match |
Fixed during this validation pass:
sv_stopspeed100 → 80;sv_maxspeed260 → 250.- Airborne "certainly air" cutoff
vel.z > 180→ 140 (NON_JUMP_VELOCITY) plus the missingm_surfaceFriction = 0.25deadstrafe window. m_surfaceFrictionfactor added to groundAccelerate(documented).- Jump ordering: was friction-before-jump; now CheckJumpButton clears the ground state first and applies its own FinishGravity → the documented triple half-gravity jump tick (changes apex: ~57 u continuous would be wrong — real 64-tick value is ~54.65 u).
- Ducked jump sets
vel.zinstead of adding. - Added missing
StayOnGround(downhill ground contact up to stepSize). - BSP contents flags corrected to the documented values
(
PLAYERCLIP0x10000,MONSTERCLIP0x20000,LADDER0x20000000). - Added crouch/duck and ladders (
func_ladder,CONTENTS_LADDER).
Remaining unvalidated behavior: per-tick sync gains during combined
forward+strafe turning (only the pure-perpendicular gain is checked), the
ladder top-mantle (we detach instead), friction on non-default surface
materials (m_surfaceFriction from surfaceprops is always 1), subtle
corner/crease double-plane ordering, jumpbug/edgebug window exploits, and
anything driven by server lag compensation. Tests assert geometry and
velocity outcomes; they do not measure per-frame positional error over
long runs.
Ground truth caveat: no actual CS:GO recordings, demos, or server telemetry were used or are available — the suite validates against published cvars, the public SDK documentation, and community-derived analytic formulas only. If a legally-usable public demo/telemetry source is found, it can be added as a regression corpus later.
The loader targets VBSP version 20 community surf maps. It reads:
- Brushes (
SOLID/PLAYERCLIP/MONSTERCLIP,DEBRISexcluded,CONTENTS_LADDER→ climbable volume) → convex plane solids for hull collision. - Brush-entity triggers —
trigger_teleport(withtargetdestination resolution),trigger_hurt(reset to current stage),trigger_push(pushdir/speed/basevelocity),trigger_multiplezone heuristics. - Displacements → prism collision + rendered mesh.
- Faces → rendered polygons (placeholder materials).
- Entities lump → spawn (
info_player_*), stages (orderedinfo_teleport_destinationpoints). func_ladder/func_useableladderentities → ladder volumes.- Skipped/documented: pakfile, textures, lightmaps, props (
.mdl— not loaded, they don't collide), physics props, water logic, I/O connections.
Accepted inputs (all processed locally in your browser — files are never uploaded to a server):
| Format | Notes |
|---|---|
.bsp |
version 20 only |
.bsp.bz2 |
fastdl-packed maps (bunzip in-browser) |
.vpk |
Steam Workshop container — extracts the first .bsp inside (v1/v2, single-file *_dir.vpk with embedded data or preload; multi-archive 001_… sets are not supported — extract the .bsp first) |
| URL fetch | the URL field fetches any of the above if the host sends permissive CORS headers; many mirrors don't, so drag & drop is the reliable path |
Unsupported / requires conversion first:
- BSP versions other than 20 (older CS:S v19, newer v21+): convert or pick another map.
- Multi-file VPK sets (
*_dir.vpk+*_000.vpkshards): extract the.bspyourself first (e.g. with a VPK tool). .nav,.ain, packed cubemaps, custom textures: ignored/dropped on load.- Anything fetched from Valve by this code: never happens. You bring your own legally-downloaded files.
Community surf maps are made by their authors and hosted at community sources — do not commit them to this repo and check each map's license before redistributing.
- Steam Workshop for CS:GO (
steamcommunity.com/app/730/workshop): subscribe and the client downloads a.vpktosteamapps/workshop/content/730/<item_id>/. Drag that file onto the page. - SteamCMD (no client needed):
scripts/get-workshop-map.sh <workshop_id> [outdir]wrapssteamcmd +login anonymous +workshop_download_item 730 <id> +quitand copies the result out for you. - Community map sites/mirrors often distribute raw
.bspor.bsp.bz2files directly — those drag & drop too.
- Spawn:
info_player_start/info_player_teamspawn/CT/T spawn entities (bundled map: primitive spawn). - Stages: ordered
info_teleport_destinationentities (sorted by targetname) become stage starts;trigger_multiplezone heuristics advance the stage. trigger_teleport→ teleports to the resolved destination entity origin.trigger_hurt(and falling into reset volumes) → reset to current stage start with zero velocity.trigger_push→ continuouspushdir*speedacceleration while inside, plus one-shotbasevelocityon entry when set.R→ manual stage reset.
The status line always shows horizontal speed, total speed, air/ground
state, stage and timer. F3/` additionally shows position, velocity,
plane normals hit this tick, and renders collision hulls + trigger
volumes as wireframes (solids near the player only — perf guard) so you
can inspect collision geometry directly.
npm ci
npm run dev # vite dev server
npm test # vitest: movement, clip, collision, triggers, BSP parsing
npm run build # typecheck + production build → dist/Test suite covers: ClipVelocity & multi-plane resolution, friction/accelerate
formulas, air strafe speed gain, jump impulse & autobhop latch, swept-AABB
blocking (no tunneling through thin walls, no phantom floors), ramp sliding,
step-up limits, trigger teleport/hurt/push/basevelocity, stage-reset
behavior, and BSP header/lump/entity parsing on a synthetic VBSP plus
bz2/VPK unwrapping. test/validation.test.ts adds the analytic
movement-model validation described above (gravity, friction/accelerate
sequences, air-strafe gain, deadstrafe window, jump apex/airtime, ramp clip
geometry, duck hulls, ladders).
src/move/ vec math, constants, ClipVelocity, playerTick movement
src/collision/ convex brushes, BVH broadphase, swept-AABB trace, world
src/bsp/ VBSP v20 parser, entity parser, map conversion, VPK, unpack
src/map/ primitives (box/wedge), bundled test map, zones/triggers
src/game/ three.js renderer, HUD, input, game loop
test/ vitest suite (see above)
scripts/ get-workshop-map.sh (local SteamCMD fetch helper)
- All code and bundled geometry in this repo is original, MIT licensed.
- No Valve content, no Source engine code (leaked or otherwise), and no community maps are distributed here.
- Workshop maps remain under their authors' terms — download and play them locally; don't redistribute.