Skip to content

About

Clean-room CS:GO-style surf movement game in the browser — brush-based collision, BSP v20 map loading (local files only), original test map. MIT licensed.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

cleanroom-surf

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.

Controls

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

What is exact vs approximate

This is an honest list, not marketing.

Matches the documented Source movement model (numerically validated — see Movement validation below):

  • 64-tick fixed timestep simulation (tickRate configurable in src/move/constants.ts, everything else scales off dt).
  • Documented FullWalkMove tick order: duck → ladder check → StartGravity (half step) → CheckJumpButton → friction → accelerate/air-accelerate → move+clip → CategorizePosition → FinishGravity → sv_maxvelocity clamp. 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/accelspeed formulation, m_surfaceFriction factor), sv_maxspeed cap 250 (the unarmed/knife move speed; the bare sv_maxspeed cvar is 320).
  • Air accelerate sv_airaccelerate with the 30 u/s wishspeed projection cap (sv_air_max_wishspeed) — the mechanism that makes strafing gain speed — with accelspeed computed from the uncapped wishspeed and multiplied by m_surfaceFriction (documented behavior).
  • The CS:GO deadstrafe quirk: m_surfaceFriction drops to 0.25 while airborne and 0 < vel.z ≤ 140 (NON_JUMP_VELOCITY), damping air accel during the early rise.
  • Gravity sv_gravity 800 applied as two half-steps per tick (semi-implicit Euler); sv_maxvelocity 3500 per-component clamp.
  • Jump impulse sv_jump_impulse 301.993377 (= √(2·800·57)); ducked jumps set vel.z instead of adding. Autobhop off by default.
  • Slide-on-ramp collision: ClipVelocity (overbounce 1.0) with the normal.z ≥ 0.7 walkable threshold, multi-plane resolution (up to 5 clip planes, crease slide on two planes), MAX_BUMPS = 4 iteration loop.
  • StayOnGround snap-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 > 140 is 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_LADDER volumes, documented LadderMove — short hull trace toward the wish direction attaches, climb speed 200 u/s decomposed along the ladder plane, sv_ladder_dampen steep-incidence dampening, jump pops off at 270 u/s along the ladder normal.
  • Player AABB 32 × 32 × 72 swept 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/32 contact 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 in src/move/constants.ts.
  • Ground-bump corner cases (Source's special onGround velocity 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 * speed is applied as continuous acceleration while inside; basevelocity applies 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_hurt and 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_detail brushes 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.

Movement validation

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 / srcds dumps): sv_gravity 800, sv_accelerate 5.5, sv_airaccelerate 12 (surf 100–150), sv_friction 5.2, sv_stopspeed 80, sv_maxspeed 320 cvar / 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.25 surface-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_stopspeed 100 → 80; sv_maxspeed 260 → 250.
  • Airborne "certainly air" cutoff vel.z > 180 → 140 (NON_JUMP_VELOCITY) plus the missing m_surfaceFriction = 0.25 deadstrafe window.
  • m_surfaceFriction factor added to ground Accelerate (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.z instead of adding.
  • Added missing StayOnGround (downhill ground contact up to stepSize).
  • BSP contents flags corrected to the documented values (PLAYERCLIP 0x10000, MONSTERCLIP 0x20000, LADDER 0x20000000).
  • 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.

BSP loading (community surf maps)

The loader targets VBSP version 20 community surf maps. It reads:

  • Brushes (SOLID/PLAYERCLIP/MONSTERCLIP, DEBRIS excluded, CONTENTS_LADDER → climbable volume) → convex plane solids for hull collision.
  • Brush-entity triggers — trigger_teleport (with target destination resolution), trigger_hurt (reset to current stage), trigger_push (pushdir/speed/basevelocity), trigger_multiple zone heuristics.
  • Displacements → prism collision + rendered mesh.
  • Faces → rendered polygons (placeholder materials).
  • Entities lump → spawn (info_player_*), stages (ordered info_teleport_destination points).
  • func_ladder/func_useableladder entities → 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.vpk shards): extract the .bsp yourself 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.

Where to get maps

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 .vpk to steamapps/workshop/content/730/<item_id>/. Drag that file onto the page.
  • SteamCMD (no client needed): scripts/get-workshop-map.sh <workshop_id> [outdir] wraps steamcmd +login anonymous +workshop_download_item 730 <id> +quit and copies the result out for you.
  • Community map sites/mirrors often distribute raw .bsp or .bsp.bz2 files directly — those drag & drop too.

How resets/stages work

  • Spawn: info_player_start/info_player_teamspawn/CT/T spawn entities (bundled map: primitive spawn).
  • Stages: ordered info_teleport_destination entities (sorted by targetname) become stage starts; trigger_multiple zone 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 → continuous pushdir*speed acceleration while inside, plus one-shot basevelocity on entry when set.
  • R → manual stage reset.

Debug overlay

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.

Development

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).

Repo layout

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)

Legal notes

  • 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.

About

Clean-room CS:GO-style surf movement game in the browser — brush-based collision, BSP v20 map loading (local files only), original test map. MIT licensed.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages