Skip to main content

Platform Player Controllers

These two example controllers give you everything from a simple jump-and-gravity player to a full-featured platformer with animation control and moving platform support. This follows the standard player spawning model.


Basic Character Controller​

Move and jump with keyboard controls! Fully multiplayer!

Click here to open project and view code

Code​

import { Behavior, CharacterController, RichText, Vector2, syncedValue } from "@dreamlab/engine";

// A very simple platformer controller, attach to a PlayerController Entity.
// This follows the https://docs.dreamlab.gg/guide/network-authority/#standard-player-spawning-model

export default class PlayerController extends Behavior {
#controller = this.entity.cast(CharacterController);

@syncedValue() speed = 10;
@syncedValue() jumpForce = 20;
@syncedValue() jumpAcceleration = 40;
@syncedValue() gravity = 90;
@syncedValue() maxJumpTime = 1; // Maximum duration the jump key affects the jump

@syncedValue() points = 0;

#verticalVelocity = 0;
#jumpTimeCounter = 0;

#left = this.inputs.create("@movement/left", "Move Left", "KeyA");
#right = this.inputs.create("@movement/right", "Move Right", "KeyD");
#jump = this.inputs.create("@movement/jump", "Jump", "Space");

onInitializeClient() {
if (!this.hasAuthority()) return;
this.values.get("points")?.onChanged((newPoints: number) => {
this.game.local!._.CoinCounter.cast(RichText).text = "Coins: " + newPoints;
});
}

onTickClient(): void {
if (!this.hasAuthority()) return;

const deltaTime = this.game.physics.tickDelta / 1_000; // Convert to seconds

let horizontalInput = 0;
if (this.#right.held) horizontalInput += 1;
if (this.#left.held) horizontalInput -= 1;

const horizontalVelocity = horizontalInput * this.speed;

// Jumping logic
if (this.#jump.pressed && this.#controller.isGrounded) {
this.#verticalVelocity = this.jumpForce;
this.#jumpTimeCounter = 0;
}

if (this.#jump.held && this.#jumpTimeCounter < this.maxJumpTime) {
// Apply upward acceleration while the jump key is held
this.#verticalVelocity += this.jumpAcceleration * deltaTime;
this.#jumpTimeCounter += deltaTime;
}

// Create movement vector
const movement = new Vector2(horizontalVelocity * deltaTime, this.#verticalVelocity * deltaTime);

if (!this.#controller.isGrounded) this.#verticalVelocity -= this.gravity * deltaTime;

this.entity.pos = this.entity.pos.add(movement);
}
}

Advanced Platform Player Controller​

Click here to open project and view code


info

This demo is multiplayer! Try opening another browser tab to this page!

Overview​

A complete 2D character controller for platformer games. Includes variable jump height, coyote time, head collision checks, and sticking to moving platforms.

It uses Dreamlab's built-in CharacterController for physics and Rapier raycasts for grounded detection, plus animation references for idle/run/jump/fall states.

Code​

// This follows the https://docs.dreamlab.gg/guide/network-authority/#standard-player-spawning-model

import type { AnimatedSprite, Empty, Entity } from "@dreamlab/engine";
import {
Behavior,
CharacterController,
EntityByRefAdapter,
RelativeEntity,
syncedValue,
Vector2,
Vector2Adapter,
} from "@dreamlab/engine";
import type { Cuboid } from "@dreamlab/vendor/rapier.ts";
import RAPIER from "@dreamlab/vendor/rapier.ts";
import MovingPlatform from "./moving-platform.ts";

export default class PlatformPlayer extends Behavior {
#character: CharacterController = this.entity.cast(CharacterController);

@syncedValue() speed = 10;
@syncedValue() jumpForce = 20;
@syncedValue() jumpAcceleration = 40;
@syncedValue() gravity = 90;
@syncedValue() maxJumpTime = 1;
@syncedValue() coyoteTime = 0;

horizontalVelocity = 0;
verticalVelocity = 0;
grounded = false;
#jumpTimeCounter = 0;
#isJumping = false;

#left = this.inputs.create("@movement/left", "Move Left", "KeyA");
#right = this.inputs.create("@movement/right", "Move Right", "KeyD");
#jump = this.inputs.create("@movement/jump", "Jump", "Space");

@syncedValue(EntityByRefAdapter)
stickingTo: Entity | undefined;

@syncedValue(Vector2Adapter)
stickingDiff: Vector2 = new Vector2({ x: 0, y: 0 });

@syncedValue(RelativeEntity) idleAnim: AnimatedSprite;
@syncedValue(RelativeEntity) runAnim: AnimatedSprite;
@syncedValue(RelativeEntity) walkAnim: AnimatedSprite;
@syncedValue(RelativeEntity) jumpAnim: AnimatedSprite;
@syncedValue(RelativeEntity) fallAnim: AnimatedSprite;
@syncedValue(RelativeEntity) animationsContainer: Empty;

onTickClient(): void {
// If we have no authority but are 'sticking', just match platform position
if (!this.hasAuthority() && this.stickingTo && this.stickingDiff) {
this.entity.pos = this.stickingTo.pos.add(this.stickingDiff);
return;
}

if (!this.hasAuthority()) return;

// disable all animations
this.idleAnim.enabled = false;
this.runAnim.enabled = false;
this.walkAnim.enabled = false;
this.jumpAnim.enabled = false;
this.fallAnim.enabled = false;

const deltaTime = this.game.physics.tickDelta / 1_000;
this.grounded = this.#character.isGrounded;

// --- 1) HORIZONTAL INPUT ---
let horizontalInput = 0;
if (this.#right.held) horizontalInput += 1;
if (this.#left.held) horizontalInput -= 1;
this.horizontalVelocity = horizontalInput * this.speed;

if (horizontalInput > 0) {
this.animationsContainer.transform.scale.x = Math.abs(this.animationsContainer.transform.scale.x);
this.runAnim.enabled = true;
} else if (horizontalInput < 0) {
this.animationsContainer.transform.scale.x = Math.abs(this.animationsContainer.transform.scale.x) * -1;
this.runAnim.enabled = true;
} else {
this.idleAnim.enabled = true;
}

// --- 2) IF STICKING TO PLATFORM ---
if (this.stickingTo) {
if (this.#jump.pressed) {
this.#isJumping = true;
// Jumping off a moving platform
const behavior = this.stickingTo.getBehavior(MovingPlatform);
this.unstickFromPlatform();

this.verticalVelocity = this.jumpForce + behavior.motion.y;
this.horizontalVelocity += behavior.motion.x;

this.#jumpTimeCounter = 0;
this.coyoteTime = 0;

const jumpMovement = new Vector2(this.horizontalVelocity * deltaTime, this.verticalVelocity * deltaTime);
this.entity.pos = this.entity.pos.add(jumpMovement);
return;
}

// Move horizontally with platform
this.stickingDiff.x += this.horizontalVelocity * deltaTime;
if (!this.isStillOnPlatform()) {
this.unstickFromPlatform();
} else {
this.entity.pos = this.stickingTo.pos.add(this.stickingDiff);
}

return;
}

// --- 3) COYOTE TIME UPDATE ---
this.coyoteTime = this.grounded ? 0.1 : Math.max(this.coyoteTime - deltaTime, 0);

// --- 4) NORMAL MOVEMENT (NOT STICKING) ---
// Jump if pressed and we are grounded or coyote time is active
if (this.#jump.pressed && (this.grounded || this.coyoteTime > 0)) {
this.verticalVelocity = this.jumpForce;
this.#jumpTimeCounter = 0;
this.coyoteTime = 0; // consume coyote time
this.#isJumping = true;
}

// Continue jump while holding the key
if (this.#jump.held && this.#jumpTimeCounter < this.maxJumpTime) {
this.verticalVelocity += this.jumpAcceleration * deltaTime;
this.#jumpTimeCounter += deltaTime;

// -- CLAMP TO AVOID EXCESSIVE VELOCITY --
// Pick a max jump velocity you feel is reasonable.
const maxJumpVelocity = this.jumpForce + 15;
if (this.verticalVelocity > maxJumpVelocity) {
this.verticalVelocity = maxJumpVelocity;
}
}

// --- 5) GRAVITY ---
if (!this.grounded) {
if (this.coyoteTime > 0) {
// Stabilize vertical velocity during coyote time
this.verticalVelocity = 0;
} else {
// Apply gravity after coyote time ends
this.verticalVelocity -= this.gravity * deltaTime;
}
}

if (!this.grounded && !this.#isJumping && !this.stickingTo) {
this.fallAnim.enabled = true;
}

// When landing, our vertical velocity is lower than -1
if (this.grounded && this.verticalVelocity < -1) {
this.#isJumping = false;
}

// Check head collision if moving up
if (this.verticalVelocity > 0) {
this.checkHeadCollision();
}

// Apply final movement
const movement = new Vector2(this.horizontalVelocity * deltaTime, this.verticalVelocity * deltaTime);

if (this.#isJumping) {
this.jumpAnim.enabled = true;
this.runAnim.enabled = false;
this.idleAnim.enabled = false;
}

this.handleFootRays(movement);
}

// After calculating movement, do foot-ray checks & apply final position
private handleFootRays(movement: Vector2): void {
const halfExtents = (this.#character.collider.shape as Cuboid).halfExtents;

// Foot rays
const leftRay = new RAPIER.Ray(
{ x: this.entity.pos.x - halfExtents.x - 0.15, y: this.entity.pos.y },
{ x: 0, y: -(halfExtents.y + 0.2) },
);
const rightRay = new RAPIER.Ray(
{ x: this.entity.pos.x + halfExtents.x + 0.15, y: this.entity.pos.y },
{ x: 0, y: -(halfExtents.y + 0.2) },
);

let foundPlatform: Entity | undefined;

// Left foot check
const leftHit = this.game.physics.world.castRay(leftRay, 1, true, undefined, undefined, this.#character.collider);
if (leftHit && this.grounded) {
const body = this.game.physics.world.colliders.get(leftHit.collider.handle) as any;
if (body) {
const ref = body.userData.entityRef as string;
const e = this.game.entities.lookupByRef(ref);
if (e?.getBehaviorIfExists(MovingPlatform)) {
foundPlatform = e;
}
}
}

// Right foot check
if (!foundPlatform) {
const rightHit = this.game.physics.world.castRay(
rightRay,
1,
true,
undefined,
undefined,
this.#character.collider,
);
if (rightHit && this.grounded) {
const body = this.game.physics.world.colliders.get(rightHit.collider.handle) as any;
if (body) {
const ref = body.userData.entityRef as string;
const e = this.game.entities.lookupByRef(ref);
if (e?.getBehaviorIfExists(MovingPlatform)) {
foundPlatform = e;
}
}
}
}

// Stick to platform if found & grounded
if (foundPlatform && !this.stickingTo) {
this.stickingTo = foundPlatform;
// perfectly stick to the top of the platform
const platformHeight = foundPlatform.globalTransform.scale.y;
const playerHeight = this.entity.globalTransform.scale.y;
const verticalOffset = platformHeight / 2 + playerHeight / 2;
this.stickingDiff = this.entity.pos.sub(foundPlatform.pos);
this.stickingDiff.y = verticalOffset;
}

// If not sticking, apply movement
if (!this.stickingTo) {
this.entity.pos = this.entity.pos.add(movement);
}
}

/**
* HEAD COLLISION CHECK:
* If the player is moving upward, cast rays above the head.
* If there's a hit, clamp verticalVelocity = 0.
*/
// #region checkHeadCollision()
private checkHeadCollision(): void {
const halfExtents = (this.#character.collider.shape as Cuboid).halfExtents;
const world = this.game.physics.world;

const leftHeadRay = new RAPIER.Ray(
{ x: this.entity.pos.x - halfExtents.x, y: this.entity.pos.y },
{ x: 0, y: halfExtents.y + 0.1 },
);
const midHeadRay = new RAPIER.Ray({ x: this.entity.pos.x, y: this.entity.pos.y }, { x: 0, y: halfExtents.y + 0.1 });
const rightHeadRay = new RAPIER.Ray(
{ x: this.entity.pos.x + halfExtents.x, y: this.entity.pos.y },
{ x: 0, y: halfExtents.y + 0.1 },
);

// Cast each ray. If any hit, we clamp upward velocity.
const leftHit = world.castRay(leftHeadRay, 1, true, undefined, undefined, this.#character.collider);
const midHit = world.castRay(midHeadRay, 1, true, undefined, undefined, this.#character.collider);
const rightHit = world.castRay(rightHeadRay, 1, true, undefined, undefined, this.#character.collider);

if (leftHit || midHit || rightHit) {
// Stop upward movement
this.verticalVelocity = 0;
// End the jump so holding the key doesn’t keep pushing upward
this.#jumpTimeCounter = this.maxJumpTime;
}
}

/**
* Unstick from the current platform.
*/
// #region unstick()
private unstickFromPlatform() {
this.stickingTo = undefined;
this.stickingDiff = new Vector2({ x: 0, y: 0 });
}

/**
* Check if we are still on the same platform we stuck to.
* If the ray no longer hits that platform, we detach.
*/
// #region isStillOnPlatform()
private isStillOnPlatform(): boolean {
if (!this.stickingTo) return false;

const halfExtents = (this.#character.collider.shape as Cuboid).halfExtents;

// Left foot ray
const leftRay = new RAPIER.Ray(
{ x: this.entity.pos.x - halfExtents.x - 0.15, y: this.entity.pos.y },
{ x: 0, y: -(halfExtents.y + 0.5) },
);
// Right foot ray
const rightRay = new RAPIER.Ray(
{ x: this.entity.pos.x + halfExtents.x + 0.15, y: this.entity.pos.y },
{ x: 0, y: -(halfExtents.y + 0.5) },
);

// Check left foot
const leftHit = this.game.physics.world.castRay(leftRay, 1, true, undefined, undefined, this.#character.collider);
if (leftHit) {
const leftBody = this.game.physics.world.colliders.get(leftHit.collider.handle) as any;
if (leftBody) {
const leftRef = leftBody.userData.entityRef as string;
if (leftRef === this.stickingTo.ref) {
return true; // left foot is still on platform
}
}
}

// Check right foot
const rightHit = this.game.physics.world.castRay(rightRay, 1, true, undefined, undefined, this.#character.collider);
if (rightHit) {
const rightBody = this.game.physics.world.colliders.get(rightHit.collider.handle) as any;
if (rightBody) {
const rightRef = rightBody.userData.entityRef as string;
if (rightRef === this.stickingTo.ref) {
return true; // right foot is still on platform
}
}
}

// Neither foot saw the platform => not on it anymore
return false;
}
}