Moving the Player

Crossy Road with Three.js ● Chapter 5

Moving the player on the map is more complex than moving the vehicles. The player can move in all directions, bump into trees, or be hit by cars, and it shouldn’t be able to move outside the map.

In this chapter, we are focusing on two parts: collecting user input and executing the movement commands. Player movement is not instant. We need to collect the movement commands into a queue and execute them one by one.

Collecting User Inputs

We are going to collect user inputs and put them into a queue. We collect both click events from control buttons on the screen and keyboard events.

Defining the Possible Move Directions

The player can move in four directions: forward, backward, left, and right. We extend our types file with a new type, MoveDirection, representing these directions.

src/types.ts
26 collapsed lines
import * as THREE from "three";
export type RowType = "forest" | "car" | "truck";
export type Row =
| {
type: "forest";
trees: { tileIndex: number; height: number }[];
}
| {
type: "car";
direction: boolean;
speed: number;
vehicles: {
initialTileIndex: number;
color: THREE.ColorRepresentation;
}[];
}
| {
type: "truck";
direction: boolean;
speed: number;
vehicles: {
initialTileIndex: number;
color: THREE.ColorRepresentation;
}[];
};
export type MoveDirection = "forward" | "backward" | "left" | "right";
src/types.js
1 collapsed line
export {};

Adding State and Actions to the Player

To track the movement commands, we extend the Player component with state. We keep track the player’s position and movement queue.

Let’s add a new object to contain the player’s current position and the moves queue. The player starts at the middle of the first row, and the moves queue is initially empty.

We will also export two functions. The queueMove function adds a movement command to the end of the moves queue, and the stepCompleted function removes the first movement command from the queue and updates the player’s position accordingly.

src/components/Player.ts
import * as THREE from "three";
import type { MoveDirection } from "../types";
export const player = Player();
function Player() {
const body = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
body.position.z = 10;
return body;
}
export const position: {
currentRow: number;
currentTile: number;
} = {
currentRow: 0,
currentTile: 0,
};
export const movesQueue: MoveDirection[] = [];
export function queueMove(direction: MoveDirection) {
movesQueue.push(direction);
}
export function stepCompleted() {
const direction = movesQueue.shift();
if (direction === "forward") position.currentRow += 1;
if (direction === "backward") position.currentRow -= 1;
if (direction === "left") position.currentTile -= 1;
if (direction === "right") position.currentTile += 1;
}
src/components/Player.js
import * as THREE from "three";
export const player = Player();
function Player() {
const body = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
body.position.z = 10;
return body;
}
export const position = {
currentRow: 0,
currentTile: 0,
};
export const movesQueue = [];
export function queueMove(direction) {
movesQueue.push(direction);
}
export function stepCompleted() {
const direction = movesQueue.shift();
if (direction === "forward") position.currentRow += 1;
if (direction === "backward") position.currentRow -= 1;
if (direction === "left") position.currentTile -= 1;
if (direction === "right") position.currentTile += 1;
}

Setting up the Control Buttons

It’s time to add some controls to the screen. We add four buttons to move the player forward, left, backward, and right. Let’s define them in our HTML file.

index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<canvas class="game"></canvas>
<div id="controls">
<div>
<button id="forward">▲</button>
<button id="left">◀</button>
<button id="backward">▼</button>
<button id="right">▶</button>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<canvas class="game"></canvas>
<div id="controls">
<div>
<button id="forward">▲</button>
<button id="left">◀</button>
<button id="backward">▼</button>
<button id="right">▶</button>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

The styling for the buttons is as follows. In the React component, we wrapped the buttons into two div elements. We use the outer div to position the controls at the bottom of the screen. The inner div has a grid layout to show the buttons like this. The forward button spans all columns to make it wider.

src/style.css
body {
margin: 0;
display: flex;
}
#controls {
position: absolute;
bottom: 20px;
min-width: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
}
#controls div {
display: grid;
grid-template-columns: 50px 50px 50px;
gap: 10px;
}
#controls button {
width: 100%;
height: 40px;
background-color: white;
border: 1px solid lightgray;
box-shadow: 3px 5px 0px 0px rgba(0, 0, 0, 0.75);
cursor: pointer;
outline: none;
}
#controls button:first-of-type {
grid-column: 1/-1;
}

Adding Event Listeners

Next, we add event listeners to the buttons on the screen and keyboard event listeners to the arrow keys. They all call the player’s queueMove function with the corresponding direction. Note that we prevent the default behavior of the arrow keys to avoid scrolling the page.

src/collectUserInput.ts
import { queueMove } from "./components/Player";
document
.getElementById("forward")
?.addEventListener("click", () => queueMove("forward"));
document
.getElementById("backward")
?.addEventListener("click", () => queueMove("backward"));
document
.getElementById("left")
?.addEventListener("click", () => queueMove("left"));
document
.getElementById("right")
?.addEventListener("click", () => queueMove("right"));
window.addEventListener("keydown", (event) => {
if (event.key === "ArrowUp") {
event.preventDefault(); // Avoid scrolling the page
queueMove("forward");
} else if (event.key === "ArrowDown") {
event.preventDefault(); // Avoid scrolling the page
queueMove("backward");
} else if (event.key === "ArrowLeft") {
event.preventDefault(); // Avoid scrolling the page
queueMove("left");
} else if (event.key === "ArrowRight") {
event.preventDefault(); // Avoid scrolling the page
queueMove("right");
}
});
src/collectUserInput.js
import { queueMove } from "./components/Player";
document
.getElementById("forward")
?.addEventListener("click", () => queueMove("forward"));
document
.getElementById("backward")
?.addEventListener("click", () => queueMove("backward"));
document
.getElementById("left")
?.addEventListener("click", () => queueMove("left"));
document
.getElementById("right")
?.addEventListener("click", () => queueMove("right"));
window.addEventListener("keydown", (event) => {
if (event.key === "ArrowUp") {
event.preventDefault(); // Avoid scrolling the page
queueMove("forward");
} else if (event.key === "ArrowDown") {
event.preventDefault(); // Avoid scrolling the page
queueMove("backward");
} else if (event.key === "ArrowLeft") {
event.preventDefault(); // Avoid scrolling the page
queueMove("left");
} else if (event.key === "ArrowRight") {
event.preventDefault(); // Avoid scrolling the page
queueMove("right");
}
});

Adding the Event Listeners

Finally, in the main file we import the file containing the event listeners.

src/main.ts
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { DirectionalLight } from "./components/DirectionalLight";
import { player } from "./components/Player";
import { map, initializeMap } from "./components/Map";
import { animateVehicles } from "./animateVehicles";
import "./style.css";
import "./collectUserInput";
27 collapsed lines
const scene = new THREE.Scene();
scene.add(player);
scene.add(map);
const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);
const dirLight = DirectionalLight();
scene.add(dirLight);
const camera = Camera();
scene.add(camera);
initializeGame();
function initializeGame() {
initializeMap();
}
const renderer = Renderer();
renderer.setAnimationLoop(animate);
function animate() {
animateVehicles();
renderer.render(scene, camera);
}
src/main.js
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { DirectionalLight } from "./components/DirectionalLight";
import { player } from "./components/Player";
import { map, initializeMap } from "./components/Map";
import { animateVehicles } from "./animateVehicles";
import "./style.css";
import "./collectUserInput";
27 collapsed lines
const scene = new THREE.Scene();
scene.add(player);
scene.add(map);
const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);
const dirLight = DirectionalLight();
scene.add(dirLight);
const camera = Camera();
scene.add(camera);
initializeGame();
function initializeGame() {
initializeMap();
}
const renderer = Renderer();
renderer.setAnimationLoop(animate);
function animate() {
animateVehicles();
renderer.render(scene, camera);
}

Executing Movement Commands

Now that we have a movesQueue array and we are already collecting move commands, it’s time to execute them one by one and animate the player.

Animating the Player

Now that we started collecting user inputs, it’s time to execute the player’s movements. Let’s create a new function called animatePlayer. Its main goal is to take each move command from the movesQueue one by one, calculate the player’s progress towards executing a step, and position the player accordingly.

This function animates the player frame by frame. It will also be part of the main animate function. We use a separate moveClock that measures each step individually. We pass on false to the clock constructor so it doesn’t start automatically. The clock starts at the beginning of a step. At each animation frame, first, we check if there are any more steps to take, and if there are and we don’t currently process a step, we start the clock.

Once the clock is ticking, we animate the player from tile to tile with each step. We use the moveClock to calculate the progress between the two tiles. The progress indicator can be between 0 and 1. Zero means the player is at the beginning of a step, and one means it arrived at its new position.

At each animation frame, we call the setPosition and setRotation functions to set the player according to the progress.

Once we finish a step, we call the stepCompleted function to update the player’s current position and stop the clock. If there are more move commands in the movesQueue, the clock will restart in the following animation frame.

Now that we know how to calculate the progress for each step let’s look into how to set the player’s position and rotation based on it.

src/animatePlayer.ts
import * as THREE from "three";
import { movesQueue, stepCompleted } from "./components/Player";
const moveClock = new THREE.Clock(false);
export function animatePlayer() {
if (!movesQueue.length) return;
if (!moveClock.running) moveClock.start();
const stepTime = 0.2; // Seconds it takes to take a step
const progress = Math.min(1, moveClock.getElapsedTime() / stepTime);
setPosition(progress);
setRotation(progress);
// Once a step has ended
if (progress >= 1) {
stepCompleted();
moveClock.stop();
}
}
src/animatePlayer.js
import * as THREE from "three";
import { movesQueue, stepCompleted } from "./components/Player";
const moveClock = new THREE.Clock(false);
export function animatePlayer() {
if (!movesQueue.length) return;
if (!moveClock.running) moveClock.start();
const stepTime = 0.2; // Seconds it takes to take a step
const progress = Math.min(1, moveClock.getElapsedTime() / stepTime);
setPosition(progress);
setRotation(progress);
// Once a step has ended
if (progress >= 1) {
stepCompleted();
moveClock.stop();
}
}

The setPosition and setRotation Functions

The player will jump from tile to tile. Let’s break this down into two parts: the horizontal and vertical components of the movement.

The player moves from the current tile to the next tile in the direction of the move command. We calculate the player’s start and end positions based on the current tile and the direction of the move command. We use the THREE.MathUtils.lerp function to interpolate between the start and end positions based on the progress.

For the vertical component of the movement, we use a sine function to make the curved path. The sine function can take an input between 0 and 2 pi. Our progress is a value between 0 and 1. If we multiply the progress with pi, we map the progress value to the first half of a sine wave. The sine function will return a value between -1 and 1, or in this case 0 and 1. To make the jump higher we multiply this value with 8. This way, the highest point of the jump, in the middle of the step, will be at 8.

The player also turns in the direction of the move command. We use linear interpolation again to calculate the player’s rotation based on progress.

src/animatePlayer.ts
import * as THREE from "three";
import {
player,
position,
movesQueue,
stepCompleted,
} from "./components/Player";
import { tileSize } from "./constants";
19 collapsed lines
const moveClock = new THREE.Clock(false);
export function animatePlayer() {
if (!movesQueue.length) return;
if (!moveClock.running) moveClock.start();
const stepTime = 0.2; // Seconds it takes to take a step
const progress = Math.min(1, moveClock.getElapsedTime() / stepTime);
setPosition(progress);
setRotation(progress);
// Once a step has ended
if (progress >= 1) {
stepCompleted();
moveClock.stop();
}
}
function setPosition(progress: number) {
const startX = position.currentTile * tileSize;
const startY = position.currentRow * tileSize;
let endX = startX;
let endY = startY;
if (movesQueue[0] === "left") endX -= tileSize;
if (movesQueue[0] === "right") endX += tileSize;
if (movesQueue[0] === "forward") endY += tileSize;
if (movesQueue[0] === "backward") endY -= tileSize;
player.position.x = THREE.MathUtils.lerp(startX, endX, progress);
player.position.y = THREE.MathUtils.lerp(startY, endY, progress);
player.position.z = Math.sin(progress * Math.PI) * 8;
}
function setRotation(progress: number) {
let endRotation = 0;
if (movesQueue[0] == "forward") endRotation = 0;
if (movesQueue[0] == "left") endRotation = Math.PI / 2;
if (movesQueue[0] == "right") endRotation = -Math.PI / 2;
if (movesQueue[0] == "backward") endRotation = Math.PI;
player.rotation.z = THREE.MathUtils.lerp(
player.rotation.z,
endRotation,
progress
);
}
src/animatePlayer.js
import * as THREE from "three";
import {
player,
position,
movesQueue,
stepCompleted,
} from "./components/Player";
import { tileSize } from "./constants";
19 collapsed lines
const moveClock = new THREE.Clock(false);
export function animatePlayer() {
if (!movesQueue.length) return;
if (!moveClock.running) moveClock.start();
const stepTime = 0.2; // Seconds it takes to take a step
const progress = Math.min(1, moveClock.getElapsedTime() / stepTime);
setPosition(progress);
setRotation(progress);
// Once a step has ended
if (progress >= 1) {
stepCompleted();
moveClock.stop();
}
}
function setPosition(progress) {
const startX = position.currentTile * tileSize;
const startY = position.currentRow * tileSize;
let endX = startX;
let endY = startY;
if (movesQueue[0] === "left") endX -= tileSize;
if (movesQueue[0] === "right") endX += tileSize;
if (movesQueue[0] === "forward") endY += tileSize;
if (movesQueue[0] === "backward") endY -= tileSize;
player.position.x = THREE.MathUtils.lerp(startX, endX, progress);
player.position.y = THREE.MathUtils.lerp(startY, endY, progress);
player.position.z = Math.sin(progress * Math.PI) * 8;
}
function setRotation(progress) {
let endRotation = 0;
if (movesQueue[0] == "forward") endRotation = 0;
if (movesQueue[0] == "left") endRotation = Math.PI / 2;
if (movesQueue[0] == "right") endRotation = -Math.PI / 2;
if (movesQueue[0] == "backward") endRotation = Math.PI;
player.rotation.z = THREE.MathUtils.lerp(
player.rotation.z,
endRotation,
progress
);
}

Using the Player Animation

It’s finally time to add the new animatePlayer function to the animate loop.

src/main.ts
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { DirectionalLight } from "./components/DirectionalLight";
import { player } from "./components/Player";
import { map, initializeMap } from "./components/Map";
import { animateVehicles } from "./animateVehicles";
import { animatePlayer } from "./animatePlayer";
import "./style.css";
import "./collectUserInput";
21 collapsed lines
const scene = new THREE.Scene();
scene.add(player);
scene.add(map);
const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);
const dirLight = DirectionalLight();
scene.add(dirLight);
const camera = Camera();
scene.add(camera);
initializeGame();
function initializeGame() {
initializeMap();
}
const renderer = Renderer();
renderer.setAnimationLoop(animate);
function animate() {
animateVehicles();
animatePlayer();
renderer.render(scene, camera);
}
src/main.js
import * as THREE from "three";
import { Renderer } from "./components/Renderer";
import { Camera } from "./components/Camera";
import { DirectionalLight } from "./components/DirectionalLight";
import { player } from "./components/Player";
import { map, initializeMap } from "./components/Map";
import { animateVehicles } from "./animateVehicles";
import { animatePlayer } from "./animatePlayer";
import "./style.css";
import "./collectUserInput";
21 collapsed lines
const scene = new THREE.Scene();
scene.add(player);
scene.add(map);
const ambientLight = new THREE.AmbientLight();
scene.add(ambientLight);
const dirLight = DirectionalLight();
scene.add(dirLight);
const camera = Camera();
scene.add(camera);
initializeGame();
function initializeGame() {
initializeMap();
}
const renderer = Renderer();
renderer.setAnimationLoop(animate);
function animate() {
animateVehicles();
animatePlayer();
renderer.render(scene, camera);
}

Fixing Animation and Adding a Cap on the Player

Now, if you start moving around, you notice the player sinks halfway into the ground after the first step. This is because the animation assumes that the player’s resting position is at zero. We position boxes by their center, and this box should be above the ground by half its size.

To fix this, let’s wrap the player into a group. The group’s resting position will be at zero, and the box inside of it will be elevated relative to the group.

Now that we rotate the player in its direction, it’s also handy to have a visual indicator of the direction the player is facing. We add a new mesh that serves as a little cap on top of the body.

If we did everything right, the player should be able to move around the game board. The player should move forward, backward, left, and right and rotate accordingly. However, we haven’t added any hit detection so far. The player can move through trees, vehicles, and even off the game board. We will fix this in the next chapters.

src/components/Player.ts
import * as THREE from "three";
import type { MoveDirection } from "../types";
export const player = Player();
function Player() {
const player = new THREE.Group();
const body = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
body.castShadow = true;
body.receiveShadow = true;
body.position.z = 10;
player.add(body);
const cap = new THREE.Mesh(
new THREE.BoxGeometry(2, 4, 2),
new THREE.MeshLambertMaterial({
color: 0xf0619a,
flatShading: true,
})
);
cap.position.z = 21;
cap.castShadow = true;
cap.receiveShadow = true;
player.add(cap);
return player;
}
22 collapsed lines
export const position: {
currentRow: number;
currentTile: number;
} = {
currentRow: 0,
currentTile: 0,
};
export const movesQueue: MoveDirection[] = [];
export function queueMove(direction: MoveDirection) {
movesQueue.push(direction);
}
export function stepCompleted() {
const direction = movesQueue.shift();
if (direction === "forward") position.currentRow += 1;
if (direction === "backward") position.currentRow -= 1;
if (direction === "left") position.currentTile -= 1;
if (direction === "right") position.currentTile += 1;
}
src/components/Player.js
import * as THREE from "three";
export const player = Player();
function Player() {
const player = new THREE.Group();
const body = new THREE.Mesh(
new THREE.BoxGeometry(15, 15, 20),
new THREE.MeshLambertMaterial({
color: "white",
flatShading: true,
})
);
body.castShadow = true;
body.receiveShadow = true;
body.position.z = 10;
player.add(body);
const cap = new THREE.Mesh(
new THREE.BoxGeometry(2, 4, 2),
new THREE.MeshLambertMaterial({
color: 0xf0619a,
flatShading: true,
})
);
cap.position.z = 21;
cap.castShadow = true;
cap.receiveShadow = true;
player.add(cap);
return player;
}
19 collapsed lines
export const position = {
currentRow: 0,
currentTile: 0,
};
export const movesQueue = [];
export function queueMove(direction) {
movesQueue.push(direction);
}
export function stepCompleted() {
const direction = movesQueue.shift();
if (direction === "forward") position.currentRow += 1;
if (direction === "backward") position.currentRow -= 1;
if (direction === "left") position.currentTile -= 1;
if (direction === "right") position.currentTile += 1;
}
Previous:
Animating the Vehicles
Next:
Restricting Player Movement