visitor@unsurf:~$ npm info @unsurf/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.
npm i @unsurf/cs-movementimport { 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.
}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); // ~250bindInput() and loadSettings() / saveSettings() need a browser. Everything else runs anywhere — Node, Deno, Bun, Workers.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 is the whole simulation. One tick(dt) call runs the full pipeline in order:
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:
| Field | Type | Meaning |
|---|---|---|
origin | Vec3 | World-space position (Source units, Y-up) |
velocity | Vec3 | Velocity (Source units/sec) |
yaw | number | View angle in degrees, 0 looks down -Z |
pitch | number | Vertical look angle in degrees |
onGround | boolean | True when grounded |
groundNormal | Vec3 | Normal of the standing surface |
ducked | boolean | Duck state |
duckFrac | number | 0→1 eye-height lerp progress |
surfing | boolean | True while riding a surf slope |
onLadder | LadderVolume | null | Currently gripped ladder, if any |
horizontalSpeed | number | length2D(velocity) — the HUD number |
eyeHeight | number | Lerped eye height for duck state |
stamina | number | Fatigue pool (0..max) when enabled |
lastHopQuality | 'perfect' | 'normal' | null | Quality of the last takeoff |
landingVelocity | Vec3 | Velocity snapshotted at last landing |
prevPos / currPos | Vec3 | Per-tick position snapshots for interpolation |
prevEye / currEye | number | Per-tick eye-height snapshots |
landPunch | number | Downward 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 + mousebindInput(element) wires:
| Key | Action |
|---|---|
| W / A / S / D | forward / left / back / right |
| Space | jump (held) |
| Shift | walk |
| Ctrl / C | duck |
| R | reset (respawn) |
| Mouse (pointer lock) | yaw / pitch |
| Mousewheel | jump (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);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)| Setting | Default | Meaning |
|---|---|---|
sensitivity | 1.5 | Mouse sensitivity multiplier |
mYaw | 0.022 | Degrees per mouse count (CS:GO m_yaw) |
fov | 90 | Horizontal FOV, CS:GO 4:3 terms (render-only) |
tickRate | 128 | Advisory — you choose the dt passed to tick() |
autobhop | true | Holding jump re-hops every grounded tick |
bhopSpeedClamp | true | Clamp takeoff to 1.1× maxspeed |
noPrestrafe | true | Hard ceiling on ground speed (nopre) |
airAccelerate | 100 | sv_airaccelerate — CS:GO default is 12 |
runSpeed | 250 | Max ground speed (units/sec) |
walkSpeed | 130 | Max walk speed (units/sec) |
crouchSpeed | 85 | Max crouch speed (units/sec) |
viewPunch | false | Landing view-punch offset |
stamina | disabled | CS2 fatigue pool (nested object) |
perf | disabled | Perfect-bhop velocity carry (nested object) |
| autobhop | bhopSpeedClamp | Behavior |
|---|---|---|
| true | true (default) | KZ/HNS — holding jump re-hops, takeoff capped at 1.1× maxspeed |
| true | false | Auto-rehop, no clamp — speed compounds without bound |
| false | either | Vanilla — 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.
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.
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.
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:
autobhop never gets the carry (held-jump always re-fires instantly, defeating the clamp).player.lastHopQuality reports 'perfect', 'normal', or null — flash a HUD element off it, drive an audio cue, whatever.
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.
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.
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.
Everything is exported from @unsurf/cs-movement.
| Signature | Description |
|---|---|
new PlayerController(world, settings, spawn, opts?) | Main class. opts.log fires on anomalies. |
.tick(dt: number): void | One simulation step. dt in seconds (1/128 for 128-tick). |
.respawn(): void | Reset to spawn point. |
.bindInput(el: HTMLElement): void | Wire keyboard + pointer-lock mouse to an element. |
.tickHistoryText(): string | Last 384 ticks as newline-joined text. |
.input: InputState | Mutable — 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. |
| Signature | Description |
|---|---|
.solids: Brush[] | Collision geometry (flat list). |
.ladders: LadderVolume[] | Ladder volumes. |
.trace(start, end, mins, maxs): TraceResult | Hull sweep against all solids. |
.isPositionFree(origin, mins, maxs): boolean | Does the hull fit here? |
.ladderAt(origin, mins, maxs): LadderVolume | null | Which ladder contains this hull? |
| Signature | Description |
|---|---|
brushFromAABB(min: Vec3, max: Vec3): Brush | Axis-aligned box from two corners. |
brushFromOrientedBox(center, halfExtents, ax, ay, az): Brush | Oriented box — for ramps and surf. |
traceBox(start, end, mins, maxs, brushes): TraceResult | Minkowski-expansion hull trace. |
boxInBrush(origin, mins, maxs, brush): boolean | Box containment test. |
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;
}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
}| Function | Signature |
|---|---|
vec3 | vec3(x?, y?, z?): Vec3 — constructor |
copy | copy(out, a): Vec3 — out = a |
set | set(out, x, y, z): Vec3 |
add | add(out, a, b): Vec3 |
sub | sub(out, a, b): Vec3 |
addScaled | addScaled(out, a, b, s): Vec3 — a + b*s |
scale | scale(out, a, s): Vec3 |
dot | dot(a, b): number |
cross | cross(out, a, b): Vec3 |
length | length(a): number |
length2D | length2D(a): number — sqrt(x² + z²) |
lengthSq | lengthSq(a): number |
normalize | normalize(out, a): Vec3 |
clone | clone(a): Vec3 |
| Function | Description |
|---|---|
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 |
| Constant | Value | Source engine equivalent |
|---|---|---|
GRAVITY | 800 | sv_gravity (u/s²) |
RUN_SPEED | 250 | knife/no-weapon speed |
WALK_SPEED | 130 | +speed (shift) |
CROUCH_SPEED | 85 | crouch speed |
STANDABLE_NORMAL | 0.7 | ground if normal.y ≥ 0.7 (~45.57°) |
M_YAW | 0.022 | m_yaw / m_pitch |
PITCH_CLAMP | 89 | max vertical look angle |
AIR_ACCELERATE | 100 | sv_airaccelerate (CS:GO default: 12) |
AIR_SPEED_CAP | 30 | wishspeed clamp while airborne |
DIST_EPSILON | 0.03125 | Source trace epsilon (1/32 unit) |
DEFAULT_TICK_RATE | 128 | tick rate advisory |
MAX_FRAME_TIME | 0.1 | rAF delta clamp |
// 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,
);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());// 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);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)
}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),
));
}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.
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.
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.
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().