Skip to main content

Top Down Character Controller

A simple 8-directional character controller for top-down games with animation support.

This follows the standard player spawning model.


Basic Top Down Controller

Move in 8 directions with WASD controls and animation state handling.

Overview

A straightforward top-down character controller with movement and idle animations. Supports directional sprites and smooth 8-directional movement.

Code

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

export default class Player extends Behavior {
@syncedValue()
speed = 2.0;

#up = this.inputs.create("@movement/up", "Move Up", "KeyW");
#down = this.inputs.create("@movement/down", "Move Down", "KeyS");
#left = this.inputs.create("@movement/left", "Move Left", "KeyA");
#right = this.inputs.create("@movement/right", "Move Right", "KeyD");

private state: "idle" | "moving" = "idle";
@syncedValue()
public dir: "left" | "right" = "right";

onInitializeClient(): void {
super.onInitialize();
if (!this.hasAuthority()) return;

this.updateMovementAnim();
}

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

let dx = (this.#right.held ? 1 : 0) - (this.#left.held ? 1 : 0);
let dy = (this.#up.held ? 1 : 0) - (this.#down.held ? 1 : 0);

if (dx !== 0 || dy !== 0) {
this.state = "moving";
if (dx !== 0) {
this.dir = dx > 0 ? "right" : "left";
}

const dt = this.game.physics.tickDelta / 1000;
let movement = new Vector2(dx, dy);

// Normalize diagonal movement for consistent speed
this.entity.pos = this.entity.pos.add(
movement.normalize().mul(this.speed * dt)
);
} else {
this.state = "idle";
}

this.updateMovementAnim();
}

private updateMovementAnim(): void {
const A = this.entity._.Animations._ as Record<string, any>;
A.Walk.enabled = this.state === "moving";
A.Idle.enabled = this.state === "idle";

// Flip sprite based on direction
const sx = this.dir === "left" ? -1 : 1;
A.Walk.transform.scale = new Vector2(sx, 1);
A.Idle.transform.scale = new Vector2(sx, 1);
}
}

Advanced Top Down Controller with Joystick Support

Click here to clone project and view code

Overview

This demo includes mobile joystick support for touch controls in addition to keyboard movement.

Player Code

import { AnimatedSprite, Behavior, syncedValue, Vector2 } from "@dreamlab/engine";
import Joystick from "./joystick.tsx";

export const ACTION_TYPES = [
"punch",
"attack",
"kick",
"stomp",
"grab",
"hit",
"entrance",
"leaving",
] as const;

export type ActionType = (typeof ACTION_TYPES)[number];

export default class Player extends Behavior {
@syncedValue()
speed = 2.0;

#up = this.inputs.create("@movement/up", "Move Up", "KeyW");
#down = this.inputs.create("@movement/down", "Move Down", "KeyS");
#left = this.inputs.create("@movement/left", "Move Left", "KeyA");
#right = this.inputs.create("@movement/right", "Move Right", "KeyD");
#interact = this.inputs.create("@interaction/use", "Use/Workout", "KeyE");

private state: "idle" | "moving" = "idle";
@syncedValue()
public dir: "left" | "right" = "right";
private isPerformingAction = false;

onInitializeClient(): void {
super.onInitialize();
if (!this.hasAuthority()) return;

this.updateMovementAnim();
}

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

let dx = (this.#right.held ? 1 : 0) - (this.#left.held ? 1 : 0);
let dy = (this.#up.held ? 1 : 0) - (this.#down.held ? 1 : 0);

let joystickSpeedMultiplier = 1;
if (dx === 0 && dy === 0 && Joystick.joystickMovement) {
const movement = Joystick.joystickMovement;

dx = movement.x;
dy = movement.y;

joystickSpeedMultiplier = Math.abs(Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)));
}

if (dx !== 0 || dy !== 0) {
this.state = "moving";
if (dx !== 0) {
this.dir = dx > 0 ? "right" : "left";
}

const dt = this.game.physics.tickDelta / 1000;

let movement = new Vector2(dx, dy);

this.entity.pos = this.entity.pos.add(
movement.normalize().mul(this.speed * dt).mul(joystickSpeedMultiplier),
);
} else {
this.state = "idle";
}

this.updateMovementAnim();
}

private updateMovementAnim(): void {
const A = this.entity._.Animations._ as Record<string, any>;
A.Walk.enabled = this.state === "moving";
A.Idle.enabled = this.state === "idle";
const sx = this.dir === "left" ? -1 : 1;
A.Walk.transform.scale = new Vector2(sx, 1);
A.Idle.transform.scale = new Vector2(sx, 1);
}

private performAction(action: ActionType): void {
this.isPerformingAction = true;
this.playAnimation(action, () => {
this.isPerformingAction = false;
this.state = "idle";
this.updateMovementAnim();
});
}

private playAnimation(action: ActionType, onComplete: () => void): void {
const A = this.entity._.Animations._ as Record<string, any>;
A.Walk.enabled = A.Idle.enabled = false;

const key = action.charAt(0).toUpperCase() + action.slice(1);
const animEnt = A[key];
if (!animEnt) {
onComplete();
return;
}

animEnt.enabled = true;
const sx = this.dir === "left" ? -1 : 1;
animEnt.transform.scale = new Vector2(sx, 1);

const spriteComp = animEnt.cast(AnimatedSprite);
const pixiSprite = spriteComp?.sprite;
if (pixiSprite) {
pixiSprite.loop = action.startsWith("workout") ? true : false;
let finished = false;
pixiSprite.onFrameChange = (frame: number) => {
if (!pixiSprite.loop && frame === pixiSprite.totalFrames - 1) {
finished = true;
}
};
const checkEnd = () => {
if (pixiSprite.loop || finished) {
pixiSprite.onFrameChange = null;
animEnt.enabled = false;
onComplete();
} else {
requestAnimationFrame(checkEnd);
}
};
requestAnimationFrame(checkEnd);
} else {
onComplete();
}
}
}

Joystick Code

Create a UILayer entity and attach this Joystick behavior to it.

import { IVector2, UIBehavior } from "@dreamlab/engine";

export default class Joystick extends UIBehavior {
private joystick;

static joystickMovement: IVector2;

onInitializeClient() {
// needed to wait for this.uiElement to be defined.

setTimeout(async () => {
const nipplejs = (await import("npm:nipplejs")).default;
this.joystick = nipplejs.create({
zone: this.uiElement as HTMLElement,
color: "white",
position: { left: "50%", bottom: "25%" },
mode: "dynamic",
size: 150,
});

// Add event listeners for joystick movement
this.joystick.on("move", (evt, data) => {
// Normalize the vector and store it
const force = data.force > 1 ? 1 : data.force;
Joystick.joystickMovement = {
x: Math.cos(data.angle.radian) * force,
y: Math.sin(data.angle.radian) * force,
};
});

// Reset movement when joystick is released
this.joystick.on("end", () => {
Joystick.joystickMovement = { x: 0, y: 0 };
});
});
}

render() {
return <div style={{ width: "100%", height: "100%", position: "relative" }}></div>;
}
}