A First-Person Controller in Babylon.js · Part 5 of 11 in the series
Part 5: Running and Jumping
Run with Shift, jump to an exact height, keep your momentum in the air, and make jumps feel fair with coyote time and a jump buffer.

Quest objectives
- Tell a key that is held from a key that was just pressed
- Run, but only forwards
- Jump to a height you choose, not a speed
- Keep momentum in the air and steer a little
- Forgive late and early jumps with coyote time and a jump buffer
After part 4 the player walks anywhere a slope allows. This part adds speed and height: running, and jumping over the gaps on the right of the course.
Pressed, not held
Walking asks whether a key is down. A jump asks whether it went down: holding Space should jump once, not again on every landing. The input class collects the keys that went down since the last frame, ignoring the repeats the operating system sends while a key is held, and forgets them at the end of the frame. Replace src/input.ts:
/**
* Which keys do what. `event.code` names the physical key, so WASD stays where it is on
* any keyboard layout.
*/
const BINDINGS = {
forward: ['KeyW', 'ArrowUp'],
back: ['KeyS', 'ArrowDown'],
left: ['KeyA', 'ArrowLeft'],
right: ['KeyD', 'ArrowRight'],
run: ['ShiftLeft', 'ShiftRight'],
jump: ['Space'],
};
export type Action = keyof typeof BINDINGS;
const BOUND = new Set(Object.values(BINDINGS).flat());
export class Input {
/** True while the mouse is captured. Keys and mouse only count then. */
locked = false;
onLockChange: (locked: boolean) => void = () => {};
private readonly canvas: HTMLCanvasElement;
private readonly held = new Set<string>();
private readonly pressed = new Set<string>();
private mouseX = 0;
private mouseY = 0;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
window.addEventListener('keydown', (event) => {
if (!this.locked || !BOUND.has(event.code)) return;
event.preventDefault();
if (!event.repeat) this.pressed.add(event.code);
this.held.add(event.code);
});
window.addEventListener('keyup', (event) => this.held.delete(event.code));
// A key let go while another window has the focus never sends its keyup.
window.addEventListener('blur', () => this.held.clear());
document.addEventListener('pointerlockchange', () => {
this.locked = document.pointerLockElement === canvas;
if (!this.locked) this.held.clear();
this.onLockChange(this.locked);
});
document.addEventListener('mousemove', (event) => {
if (!this.locked) return;
this.mouseX += event.movementX;
this.mouseY += event.movementY;
});
}
/** Captures the mouse. Browsers only allow it in answer to a click or a key press. */
async lock(): Promise<void> {
try {
// Raw mouse movement, without the acceleration of the operating system.
await this.canvas.requestPointerLock({ unadjustedMovement: true });
} catch {
// Not every platform has raw movement (Linux does not), so ask again without it.
await this.canvas.requestPointerLock().catch(() => {});
}
}
/** Whether one of the action's keys is held down. */
down(action: Action): boolean {
return BINDINGS[action].some((code) => this.held.has(code));
}
/** Whether one of the action's keys went down since the last frame. */
pressedNow(action: Action): boolean {
return BINDINGS[action].some((code) => this.pressed.has(code));
}
/** -1, 0 or 1, from a pair of opposite actions. */
axis(negative: Action, positive: Action): number {
return Number(this.down(positive)) - Number(this.down(negative));
}
/** The mouse movement since the last call, in pixels. */
takeMouse(): { x: number; y: number } {
const movement = { x: this.mouseX, y: this.mouseY };
this.mouseX = 0;
this.mouseY = 0;
return movement;
}
/** Call at the end of every frame. */
endFrame(): void {
this.pressed.clear();
}
}endFrame() has to be the last thing in a frame, after everything that asks pressedNow(). In src/main.ts:
scene.onBeforeRenderObservable.add(() => {
// After a stall, such as a tab in the background, one huge step would tunnel through walls.
const dt = Math.min(engine.getDeltaTime() / 1000, 0.05);
if (dt <= 0) return;
look.update(input);
player.update(dt);
view.update(player, look);
hud.update(player);
input.endFrame();
});The numbers
The player gets a run speed, an acceleration for the air, a jump height and two timers. PLAYER in src/settings.ts now looks like this:
/** Every number that decides how the player feels. Meters, seconds and radians. */
export const PLAYER = {
radius: 0.35,
standHeight: 1.8,
/** Eye height above the feet. */
standEye: 1.65,
walkSpeed: 4,
runSpeed: 7,
/** How fast the velocity may change on the ground and in the air, in m/s². */
groundAcceleration: 40,
airAcceleration: 10,
gravity: 20,
jumpHeight: 1.2,
/** A jump still works this long after walking off an edge. */
coyoteTime: 0.12,
/** A jump pressed this long before landing happens on landing. */
jumpBuffer: 0.12,
/**
* The steepest ground to walk on. It also decides how high an edge the rounded bottom of
* the capsule rolls over: about radius * (1 - cos(angle)) above its bottom, which floats a
* few centimeters over the ground. The 20 cm steps in the level work.
*/
maxSlopeDegrees: 60,
/** Ground this close under the feet still counts as walking. */
snapDistance: 0.3,
};Running
Add the new fields to the Player class in src/player.ts, running next to mode and the others after velocity:
running = false;
private readonly controller: PhysicsCharacterController;
private readonly level: Level;
private readonly input: Input;
private readonly look: Look;
private readonly velocity = new Vector3();
/** True from a jump until the player starts to come down. */
private rising = false;
private coyoteTimer = 0;
private jumpTimer = 0;The player runs while Shift is held and they move forward. Running backwards or sideways at full speed looks odd and is rare in games. update() works that out and counts the timers down, before the modes run:
update(dt: number): void {
if (this.controller.getPosition().y < -20) this.respawn();
const support = this.controller.checkSupport(dt, DOWN);
this.velocity.copyFrom(this.controller.getVelocity());
this.coyoteTimer = Math.max(this.coyoteTimer - dt, 0);
this.jumpTimer = Math.max(this.jumpTimer - dt, 0);
if (this.input.pressedNow('jump')) this.jumpTimer = PLAYER.jumpBuffer;
this.running = this.input.down('run') && this.input.axis('back', 'forward') > 0;
switch (this.mode) {
case 'walk':
this.walk(dt, support);
break;
case 'air':
this.air(dt, support);
break;
}
this.controller.setVelocity(this.velocity);
this.controller.integrate(dt, support, GRAVITY);
}groundSpeed() picks the speed, for the ground and for the air:
private groundSpeed(): number {
return this.running ? PLAYER.runSpeed : PLAYER.walkSpeed;
}Jumping
A jump sets the vertical speed. Which speed? The one that reaches the height you want, and physics has the answer: something thrown up at speed v against gravity g rises v² / 2g, so the speed for a height h is √(2gh). For 1.2 m at 20 m/s² that is 6.9 m/s. Setting the height rather than the speed means you can change gravity and the jump stays as high. 1.2 m clears the 1 m boxes, and the 50 cm steps one at a time.
Replace walk(), which now jumps:
private walk(dt: number, support: CharacterSurfaceInfo): void {
const grounded = support.supportedState === CharacterSupportedState.SUPPORTED;
const drop = grounded ? 0 : this.groundBelow();
if (drop === null) {
this.mode = 'air';
this.coyoteTimer = PLAYER.coyoteTime;
this.air(dt, support);
return;
}
if (this.jumpTimer > 0) {
this.jump();
return;
}
const target = this.wish(this.groundSpeed());
if (grounded) alongGround(target, support.averageSurfaceNormal);
approach(this.velocity, target, PLAYER.groundAcceleration * dt);
// Just off the ground, such as over the top of a slope: move down onto it this frame.
if (!grounded) this.velocity.y = -drop / dt;
}And replace air(), and add jump() after it:
private air(dt: number, support: CharacterSurfaceInfo): void {
if (this.jumpTimer > 0 && this.coyoteTimer > 0) {
this.jump();
return;
}
if (this.velocity.y <= 0) this.rising = false;
if (support.supportedState === CharacterSupportedState.SUPPORTED && !this.rising) {
this.mode = 'walk';
this.walk(dt, support);
return;
}
// Without input the player keeps their momentum. With it, they steer, but slowly.
if (this.input.axis('back', 'forward') || this.input.axis('left', 'right')) {
const target = this.wish(this.groundSpeed());
target.y = this.velocity.y;
approach(this.velocity, target, PLAYER.airAcceleration * dt);
}
this.velocity.y -= PLAYER.gravity * dt;
}
private jump(): void {
this.velocity.y = Math.sqrt(2 * PLAYER.gravity * PLAYER.jumpHeight);
this.rising = true;
this.mode = 'air';
this.jumpTimer = 0;
this.coyoteTimer = 0;
}rising stays true from a jump until the player starts to come down. Right after a jump the capsule is still within a few centimeters of the ground, and checkSupport() can still report it as supported. Without the flag, air() would take that for a landing, and walk() would start braking the upward speed like any other: in a test without it, the jump came out about 10 cm lower.
In the air
In the air, the player keeps their momentum: without input, nothing changes but gravity. With input, they steer towards the velocity the keys ask for, but at a quarter of the acceleration they have on the ground. That is enough to correct a jump, but not to turn around in mid-air.
Coyote time and a jump buffer
Players press jump a little too late, a frame after they walked off an edge, and a little too early, just before they land. Strict code ignores both, and the game feels unfair, even though the player was wrong. Two small timers fix it:
Coyote time, named after the cartoon coyote who runs off cliffs and only falls when he notices: for 0.12 s after walking off an edge, a jump still works.
walk()starts the timer when the ground disappears, andair()jumps while it runs.The jump buffer: a jump pressed up to 0.12 s before landing is remembered and happens on landing.
update()starts the timer on the press, andwalk()jumps while it runs.
The HUD
The first line of the HUD gets the stance, so you can see when the player runs. Replace update() in src/hud.ts:
update(player: Player): void {
const feet = player.feet();
const stance = player.running ? 'running' : 'standing';
const text = [
`${player.mode} · ${stance}`,
`${player.horizontalSpeed().toFixed(1)} m/s`,
`x ${feet.x.toFixed(1)} y ${feet.y.toFixed(2)} z ${feet.z.toFixed(1)}`,
].join('\n');
// Only touch the page when something changed.
if (text !== this.text) this.element.textContent = this.text = text;
}And the overlay in index.html lists the new keys:
<div id="overlay">
<strong>Click to play</strong>
<span>WASD move · mouse look · Shift run · Space jump · Esc pause</span>
</div>Try it
Run with Shift: the HUD shows 7.0 m/s and running. Jump on the spot, and the feet rise 1.2 m. Then go to the boxes on the right of the course and jump onto the first one. The 2 m gap to the next box takes a walking jump. The 5 m gap after it takes a running one: a jump lasts about 0.7 s before you are back at the same height, which is 2.8 m at walking speed and 4.8 m running. Walk off the end of a box and press jump a moment later, and you still jump.
In the next part the player learns to make themselves small and crawl through a tunnel.