~/unsurf

visitor@unsurf:~$ npm info @unsurf/cs-movement

cs-movement

Counter-Strike / Source-engine movement physics as a standalone TypeScript library. Bhop, surf, air-strafing, ladders, ducking, stamina, and plane-based brush collision.

zero runtime depsrenderer-agnosticheadless in Node
▌Getting Started

Install

npm i @unsurf/cs-movement

Browser

browser
import { World, PlayerController, brushFromAABB, vec3, DEFAULT_SETTINGS } from '@unsurf/cs-movement';

// 1. Build collision geometry.
const world = new World();
world.solids.push(brushFromAABB(vec3(-512, -16, -512), vec3(512, 0, 512)));

// 2. Spawn a player.
const player = new PlayerController(world, structuredClone(DEFAULT_SETTINGS), vec3(0, 8, 0));

// 3. Wire up WASD + pointer-lock mouse.
player.bindInput(canvas);

// 4. Step at a fixed tick rate, read state back each frame.
function gameLoop(dt: number) {
  player.tick(dt);
  // player.origin, player.velocity, player.onGround, etc.
}

Headless (Node)

node
import { World, PlayerController, brushFromAABB, vec3, DEFAULT_SETTINGS } from '@unsurf/cs-movement';

const world = new World();
world.solids.push(brushFromAABB(vec3(-8192, -64, -8192), vec3(8192, 0, 8192)));

const player = new PlayerController(world, structuredClone(DEFAULT_SETTINGS), vec3(0, 5, 0));
player.input.forward = true;
for (let i = 0; i < 512; i++) player.tick(1 / 128);

console.log(player.horizontalSpeed); // ~250
Note. Only bindInput() and loadSettings() / saveSettings() need a browser. Everything else runs anywhere — Node, Deno, Bun, Workers.
▌Core Concepts

World & Brushes

World holds the collision geometry: a flat list of solids: Brush[] and optionally ladders: LadderVolume[]. A Brush is a convex shape defined by its bounding planes — the same scheme as Source engine's CM_ClipBoxToBrush.

import { World, brushFromAABB, brushFromOrientedBox } from '@unsurf/cs-movement';

const world = new World();

// Axis-aligned box — two opposite corners.
world.solids.push(brushFromAABB(vec3(-512, -16, -512), vec3(512, 0, 512)));

// Oriented box — center, half-extents, and a local orthonormal basis.
// Tilt the "up" axis away from world-up and the top face becomes
// a walkable-or-surfable slope, decided purely by its normal.
world.solids.push(brushFromOrientedBox(
  vec3(0, 100, 0),        // center
  vec3(200, 8, 200),      // half-extents
  vec3(1, 0, 0),          // local X (right)
  vec3(0, 0.7, 0.7),      // local Y — tilted = surfable
  vec3(0, -0.7, 0.7),     // local Z
));

// Ladder volume — a Brush with a horizontal "facing" vector.
world.ladders.push({
  ...brushFromAABB(vec3(60, 0, -24), vec3(100, 600, 24)),
  facing: vec3(-1, 0, 0), // climbable face points away from the wall
});

A brush face whose normal is too steep to stand on ( normal.y < STANDABLE_NORMAL, ~45.57°) but not vertical gets clipped with OVERBOUNCE_SURF (no speed loss) instead of the ordinary default. player.surfing goes true while a contact plane qualifies.

World also exposes lower-level queries:

world.trace(start, end, mins, maxs)        // hull sweep → TraceResult
world.isPositionFree(origin, mins, maxs)  // does the hull fit here?
world.ladderAt(origin, mins, maxs)        // which ladder (if any)?

PlayerController

PlayerController is the whole simulation. One tick(dt) call runs the full pipeline in order:

duck → ladder → jump → ground friction / accelerate (or air accelerate) → gravity → collision → landing → categorize

It mutates the controller's own fields — no allocation per tick. The constructor:

new PlayerController(world: World, settings: Settings, spawn: Vec3, opts?: PlayerOptions)

interface PlayerOptions {
  log?: (msg: string) => void; // anomalies: unstuck pops, velocity kills
}

Read these each frame to render:

FieldTypeMeaning
originVec3World-space position (Source units, Y-up)
velocityVec3Velocity (Source units/sec)
yawnumberView angle in degrees, 0 looks down -Z
pitchnumberVertical look angle in degrees
onGroundbooleanTrue when grounded
groundNormalVec3Normal of the standing surface
duckedbooleanDuck state
duckFracnumber0→1 eye-height lerp progress
surfingbooleanTrue while riding a surf slope
onLadderLadderVolume | nullCurrently gripped ladder, if any
horizontalSpeednumberlength2D(velocity) — the HUD number
eyeHeightnumberLerped eye height for duck state
staminanumberFatigue pool (0..max) when enabled
lastHopQuality'perfect' | 'normal' | nullQuality of the last takeoff
landingVelocityVec3Velocity snapshotted at last landing
prevPos / currPosVec3Per-tick position snapshots for interpolation
prevEye / currEyenumberPer-tick eye-height snapshots
landPunchnumberDownward view-punch from hard landing (decays each tick)

Other methods:

player.respawn(): void            // reset to spawn
player.tickHistoryText(): string  // last 384 ticks as text (bug reports)
player.bindInput(target: HTMLElement): void  // browser: WASD + mouse

Input

bindInput(element) wires:

KeyAction
W / A / S / Dforward / left / back / right
Spacejump (held)
Shiftwalk
Ctrl / Cduck
Rreset (respawn)
Mouse (pointer lock)yaw / pitch
Mousewheeljump (one-tick pulse per notch)

Mousewheel bhop: each notch queues a single tick's worth of +jump, matching chasemod wheel-bhop. A burst of notches in one frame collapses into one press (Source's per-command-frame batching). A fresh notch after the previous one was consumed re-arms a new press.

Or drive player.input directly for AI, replays, or custom input:

player.input.forward = true;
player.input.jump = true;
player.yaw += 2;
player.tick(1 / 128);

Settings

Every tunable lives on one Settings object, passed to the constructor and read fresh every tick. Mutate it live — the next tick picks it up.

import { DEFAULT_SETTINGS } from '@unsurf/cs-movement';
const settings = structuredClone(DEFAULT_SETTINGS);
settings.autobhop = true;
settings.bhopSpeedClamp = false; // uncapped CS:GO-style bhop
settings.airAccelerate = 100;   // KZ server (CS:GO default is 12)
SettingDefaultMeaning
sensitivity1.5Mouse sensitivity multiplier
mYaw0.022Degrees per mouse count (CS:GO m_yaw)
fov90Horizontal FOV, CS:GO 4:3 terms (render-only)
tickRate128Advisory — you choose the dt passed to tick()
autobhoptrueHolding jump re-hops every grounded tick
bhopSpeedClamptrueClamp takeoff to 1.1× maxspeed
noPrestrafetrueHard ceiling on ground speed (nopre)
airAccelerate100sv_airaccelerate — CS:GO default is 12
runSpeed250Max ground speed (units/sec)
walkSpeed130Max walk speed (units/sec)
crouchSpeed85Max crouch speed (units/sec)
viewPunchfalseLanding view-punch offset
staminadisabledCS2 fatigue pool (nested object)
perfdisabledPerfect-bhop velocity carry (nested object)
▌Features

Bunnyhopping

autobhopbhopSpeedClampBehavior
truetrue (default)KZ/HNS — holding jump re-hops, takeoff capped at 1.1× maxspeed
truefalseAuto-rehop, no clamp — speed compounds without bound
falseeitherVanilla — jump fires only on fresh +jump press (pogo-stick rule)

The pogo-stick rule: if +jump was already held last tick, a new jump doesn't fire. This is Source engine's actual behavior — holding space doesn't auto-bhop in vanilla CS.

Surf

No separate surf mode or flag — it falls directly out of collision. Any brush face whose normal is too steep to stand on (normal.y < STANDABLE_NORMAL, ~45.57°) but not vertical gets clipped with OVERBOUNCE_SURF (no speed loss) instead of the ordinary default, and player.surfing goes true.

Build a surf ramp with brushFromOrientedBox and tilt the local Y axis into that range. The normal matters, not an explicit angle parameter — the library figures out surf/walk from geometry alone.

Air Strafing

sv_airaccelerate is faithful to CS:GO. The asymmetric addspeed/accelspeed behavior — comparing against dot(velocity, wishdir) rather than |velocity| — is what makes prestrafe and air-strafe technique work.

The air-speed cap is a soft ceiling via diminishing returns (not a hard clamp). Speeds at or below AIR_SPEED_CAP (30) are untouched; gains shrink the further past it you push. When perf.enabled, the ceiling is perf.maxAirSpeed (390) instead, approached via a diminishing-returns curve every airborne tick.

Perf — Perfect Bhop

A skill-timed instant rejump — manual input, the tick right after landing — restores your landing velocity exactly, bypassing whatever bhopSpeedClamp or friction reduced it to.

Nothing else qualifies, ever:

  • A rejump even one tick late gets nothing — no partial credit.
  • autobhop never gets the carry (held-jump always re-fires instantly, defeating the clamp).
  • A landing off a surf ramp never counts — surf speed isn't something you "cash in" as a perfect bhop.
  • The very first jump of a life never carries (gravity settling looks like a landing).

player.lastHopQuality reports 'perfect', 'normal', or null — flash a HUD element off it, drive an audio cue, whatever.

Stamina

CS2-style fatigue pool. Jumping and landing fill it; it drains back to 0 over time; while full-ish both ground speed and jump velocity are throttled by up to maxPenalty.

interface StaminaSettings {
  enabled: boolean;      // default false
  max: number;           // default 1
  jumpCost: number;      // default 0.08 (fraction of max per jump)
  landCost: number;      // default 0.05 (fraction of max per landing)
  recoveryRate: number;  // default 0.5  (fraction of max per second)
  maxPenalty: number;    // default 0.4  (speed cut at full pool)
}

Disabled by default — every bhop preset plays with this off. Read player.stamina (0..max) directly for a HUD meter.

Ducking

Hold input.duck; the hull shrinks from 32×32×72 standing to 32×32×54 ducked. duckFrac (0→1) lerps over DUCK_LERP_TIME (0.2s) for smooth eye height. Ducking mid-air pulls the feet up so the head stays put — duck onto ledges, same as CS.

Ladders

Walk into a ladder volume facing it (or touch one mid-air) to grip. Climbing uses the full 3D view basis — looking up while holding forward climbs, looking down descends. Forward + strafe stacks both contributions for CS:GO's authentic ~1.41× "fastclimb". Press jump to push off in the direction the ladder faces.

▌API Reference

Everything is exported from @unsurf/cs-movement.

PlayerController

SignatureDescription
new PlayerController(world, settings, spawn, opts?)Main class. opts.log fires on anomalies.
.tick(dt: number): voidOne simulation step. dt in seconds (1/128 for 128-tick).
.respawn(): voidReset to spawn point.
.bindInput(el: HTMLElement): voidWire keyboard + pointer-lock mouse to an element.
.tickHistoryText(): stringLast 384 ticks as newline-joined text.
.input: InputStateMutable — forward, back, left, right, jump, duck, walk, reset.
.mins / .maxs: Vec3 (getters)Current hull extents (stand or duck).
.horizontalSpeed: number (getter)length2D(velocity).
.eyeHeight: number (getter)Lerped eye height.

World

SignatureDescription
.solids: Brush[]Collision geometry (flat list).
.ladders: LadderVolume[]Ladder volumes.
.trace(start, end, mins, maxs): TraceResultHull sweep against all solids.
.isPositionFree(origin, mins, maxs): booleanDoes the hull fit here?
.ladderAt(origin, mins, maxs): LadderVolume | nullWhich ladder contains this hull?

Collision

SignatureDescription
brushFromAABB(min: Vec3, max: Vec3): BrushAxis-aligned box from two corners.
brushFromOrientedBox(center, halfExtents, ax, ay, az): BrushOriented box — for ramps and surf.
traceBox(start, end, mins, maxs, brushes): TraceResultMinkowski-expansion hull trace.
boxInBrush(origin, mins, maxs, brush): booleanBox containment test.

Collision Types

interface Plane {
  normal: Vec3;  // outward-facing unit normal
  dist: number;  // dot(normal, pointOnPlane)
}

interface Brush {
  planes: Plane[];
  min: Vec3;  // AABB broadphase bounds
  max: Vec3;
}

interface LadderVolume extends Brush {
  facing: Vec3;  // direction the climbable face points
}

interface TraceResult {
  fraction: number;    // 0..1 of move completed
  endPos: Vec3;
  normal: Vec3 | null; // hit plane, null if fraction === 1
  startSolid: boolean;
  allSolid: boolean;
}

Settings Types

interface Settings {
  sensitivity: number;
  mYaw: number;
  fov: number;
  tickRate: number;
  autobhop: boolean;
  bhopSpeedClamp: boolean;
  noPrestrafe: boolean;
  airAccelerate: number;
  runSpeed: number;
  walkSpeed: number;
  crouchSpeed: number;
  showSpeed: boolean;
  showFps: boolean;
  showDebug: boolean;
  viewPunch: boolean;
  crosshair: CrosshairSettings;
  stamina: StaminaSettings;
  perf: PerfSettings;
}

interface CrosshairSettings {
  color: string;
  size: number;
  thickness: number;
  gap: number;
  outline: boolean;
  dot: boolean;
  tStyle: boolean;
}

interface StaminaSettings {
  enabled: boolean;
  max: number;
  jumpCost: number;
  landCost: number;
  recoveryRate: number;
  maxPenalty: number;
}

interface PerfSettings {
  enabled: boolean;
  maxAirSpeed: number; // default 390
}

Math

FunctionSignature
vec3vec3(x?, y?, z?): Vec3 — constructor
copycopy(out, a): Vec3 — out = a
setset(out, x, y, z): Vec3
addadd(out, a, b): Vec3
subsub(out, a, b): Vec3
addScaledaddScaled(out, a, b, s): Vec3 — a + b*s
scalescale(out, a, s): Vec3
dotdot(a, b): number
crosscross(out, a, b): Vec3
lengthlength(a): number
length2Dlength2D(a): number — sqrt(x² + z²)
lengthSqlengthSq(a): number
normalizenormalize(out, a): Vec3
cloneclone(a): Vec3

Physics (pure functions)

FunctionDescription
applyFriction(ctx, dt)Ground friction per Source's sv_friction
accelerate(ctx, wishdir, wishspeed, dt)Ground acceleration per sv_accelerate
airAccelerate(ctx, wishdir, wishspeed, dt)Air acceleration per sv_airaccelerate
clipVelocity(in, normal, out, overbounce)Clip velocity against a contact plane
addStamina(current, cost, max)Add fatigue from a jump/landing
recoverStamina(current, rate, max, dt)Drain fatigue over time
staminaPenaltyMultiplier(stamina, max, maxPenalty)Speed multiplier from fatigue
applyAirSpeedCeiling(speed, maxAirSpeed)Diminishing-returns air speed cap

Constants

ConstantValueSource engine equivalent
GRAVITY800sv_gravity (u/s²)
RUN_SPEED250knife/no-weapon speed
WALK_SPEED130+speed (shift)
CROUCH_SPEED85crouch speed
STANDABLE_NORMAL0.7ground if normal.y ≥ 0.7 (~45.57°)
M_YAW0.022m_yaw / m_pitch
PITCH_CLAMP89max vertical look angle
AIR_ACCELERATE100sv_airaccelerate (CS:GO default: 12)
AIR_SPEED_CAP30wishspeed clamp while airborne
DIST_EPSILON0.03125Source trace epsilon (1/32 unit)
DEFAULT_TICK_RATE128tick rate advisory
MAX_FRAME_TIME0.1rAF delta clamp
▌Recipes

Integrate with Three.js

// In your render loop:
camera.position.set(
  player.origin.x,
  player.origin.y + player.eyeHeight,
  player.origin.z,
);
camera.rotation.order = "YXZ";
camera.rotation.y = THREE.MathUtils.degToRad(-player.yaw);
camera.rotation.x = THREE.MathUtils.degToRad(player.pitch);

// For smooth interpolation between fixed ticks:
const t = accumulator / dt; // 0..1 blend factor
camera.position.lerpVectors(
  new THREE.Vector3(player.prevPos.x, player.prevPos.y + player.prevEye, player.prevPos.z),
  new THREE.Vector3(player.currPos.x, player.currPos.y + player.currEye, player.currPos.z),
  t,
);

Server-Side Replay Validation

import { World, PlayerController, brushFromAABB, vec3, DEFAULT_SETTINGS } from '@unsurf/cs-movement';

const world = new World();
world.solids.push(brushFromAABB(vec3(-8192, -64, -8192), vec3(8192, 0, 8192)));

const player = new PlayerController(world, structuredClone(DEFAULT_SETTINGS), vec3(0, 5, 0), {
  log: (msg) => console.warn("[movement]", msg),
});

// Replay recorded inputs:
for (let i = 0; i < recordedInput.length; i++) {
  Object.assign(player.input, recordedInput[i]);
  player.tick(1 / 128);
}

// Dump for audit:
console.log(player.tickHistoryText());

Custom Input (AI / Replays)

// Skip bindInput entirely — drive the simulation:
player.input.forward = true;
player.input.jump = (tick % 64 < 1); // jump every 64 ticks
player.yaw += 2; // constant turn
player.tick(1 / 128);

Fixed Timestep with Interpolation

const TICK_DT = 1 / 128;
let accumulator = 0;

function gameLoop(frameDt: number) {
  accumulator += frameDt;

  while (accumulator >= TICK_DT) {
    player.tick(TICK_DT);
    accumulator -= TICK_DT;
  }

  // Blend between prevPos and currPos for smooth rendering:
  const alpha = accumulator / TICK_DT;
  const renderX = player.prevPos.x + (player.currPos.x - player.prevPos.x) * alpha;
  const renderY = player.prevPos.y + (player.currPos.y - player.prevPos.y) * alpha;
  const renderZ = player.prevPos.z + (player.currPos.z - player.prevPos.z) * alpha;
  const renderEye = player.prevEye + (player.currEye - player.prevEye) * alpha;
  // position camera at (renderX, renderY + renderEye, renderZ)
}

Build a Surf Map

const world = new World();

// Floor
world.solids.push(brushFromAABB(vec3(-2048, -16, -2048), vec3(2048, 0, 2048)));

// Surf ramp — tilt local Y ~30° from vertical
const ramp = brushFromOrientedBox(
  vec3(0, 200, 0),       // center
  vec3(512, 16, 200),    // half-extents
  vec3(1, 0, 0),         // local X
  vec3(0, 0.5, 0.866),   // local Y — tilted ~30° from vertical
  vec3(0, -0.866, 0.5),  // local Z
);
world.solids.push(ramp);

// Staircase
for (let i = 0; i < 10; i++) {
  world.solids.push(brushFromAABB(
    vec3(-100, i * 16, i * 100),
    vec3(100, (i + 1) * 16, (i + 1) * 100),
  ));
}
▌Troubleshooting

Player falls through the floor

The floor's normal is below STANDABLE_NORMAL (0.7, ~45.57°). Check your brush normals — the standing face needs normal.y >= 0.7. If it's a ramp, it might be a surf slope instead.

No surf

The ramp angle is either too flat (normal.y ≥ 0.7 = walkable) or perfectly vertical (normal.y ≈ 0 = wall). Surf needs the normal between ~0 and ~0.7. Use brushFromOrientedBox to tilt precisely.

Jump doesn't fire

With autobhop: false, jump only fires on a fresh press — if +jump was held last tick, nothing happens (pogo-stick rule). With autobhop: true, holding space should auto-rehop every grounded tick.

Performance

The simulation is zero-allocation per tick. The bottleneck is trace() — brush count matters. Each brush has 6+ planes, and the trace tests all of them. For large maps, consider spatial partitioning in your own code and only passing nearby brushes to world.trace().

▌Links