Three.js is a general-purpose 3D library for browsers. You can use it to create 3D objects, animations, and games. Take a look at the three.js examples page in the documentation to see what kind of things you can make.
In this tutorial, you will learn the basics of three.js and its game creation capabilities by making a simple obstacle avoiding 3D game. In the game, you will control a box that moves through a 3D course. The goal of the game is to avoid the obstacles and get to the end of the course.
We will create the game in Replit, which is an online integrated development environment (IDE). This means that you can do this tutorial in the browser, and it will be easy to share your game online.
Creating a new project in Replit
Head over to Replit and create a new repl. Choose HTML, CSS, JS as your project type. Give this repl a name, like "3D obstacle avoiding game".
Importing three.js to the project
Open the script.js
file in your repl. We'll import three.js by referencing it from a content distribution network (CDN) to get us up and running quickly. Add the following line to the script.js
file to import three.js from the Skypack CDN:
import * as THREE from 'https://cdn.skypack.dev/three@0.140.2';
The import
keyword is used to import a JavaScript module
. It will not work as is – we need to indicate that this script file is a module. To make this work, change the default script
tag in the index.html
file to the following:
<script type="module" src="script.js"></script>
The type=module
attribute allows us to use module features in our script.
Now we are ready to use three.js in our project.
Creating a scene with the player box
To display 3D objects on the screen, we need three things: a scene, a camera, and a renderer. Then we will place 3D objects in the scene. Most objects also require lighting to be added to see them.
Let's start by creating a scene and adding a camera. Add the following lines to the script.js
file:
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
70,
window.innerWidth / window.innerHeight,
0.1,
200
);
This creates a scene. The scene determines what will be rendered and where it will be rendered. We will add objects, a camera, and lights to our scene.
We are using a perspective camera, which is the most commonly used camera type for 3D scenes. Perspective cameras use perspective projection, which mimics the way human eyes see. The further away objects are from the camera, the smaller they appear.
The four parameters of the PerspectiveCamera
constructor function define the camera's viewing frustum, which is the field of view of our camera in the 3D world. The parameters are:
- fov - Field of view, the camera frustum vertical field of view, in degrees.
- aspect - Camera frustum aspect ratio.
- near - Camera frustum near plane, in world units.
- far - Camera frustum far plane, in world units.
The above image shows the camera's viewing frustum. The viewable objects are between the near and far plane. There are two objects in the frustum, a purple stick and an orange stick. The sticks are the same size. The dotted lines show how the sticks are projected onto the near plane, which shows how they will be seen. Their size on the near plane is different. The orange stick, which is further away from the camera, appears smaller. The units used are world units, it can be mapped to any defined unit but it is usually in meters.
Next, we will position our camera in the 3D world and make it look at a specific point. The parameters are the x, y, and z points in the 3D world. Add the following lines of code to the script.js
file:
camera.position.set(4, 4, -4);
camera.lookAt(0, 0, 2);
Let's add a renderer so that we can display the scene. We will attach it to a DOM element on our web page. Add the following lines of code:
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.render(scene, camera);
document.body.appendChild(renderer.domElement);
This creates a new renderer that will display our scene using WebGL. WebGL (Web Graphics Library) is used for rendering complex graphics, such as 3D scenes, on the web. It does this by accessing the graphics card on the user's device. The antialias
property is used to determine if anti-aliasing will be used, which is a method that smooths jagged edges on objects. This makes our 3D world look better. The size of the renderer is set to the browser width and height using the setSize
method so that our scene will take up the entire browser window. We then call the renderer's render
method to tell the renderer to draw the scene using our created scene and camera. We then add the renderer DOM element, which is a <canvas>
element, to the HTML document. The renderer uses the <canvas>
element to display the scene.
Now let's create our first 3D object, the player box. This will be the box that we move around in the game. In three.js, we need three things to create an object:
Geometry - An object contains the x, y, z points that make up a shape.
Material - The surface of the geometry. Gives the geometry color and texture.
Mesh - Geometry + material. This is what we will add to our scene.
Add the following lines above the renderer
declaration:
const geometry = new THREE.BoxGeometry(1,1,1);
const material = new THREE.MeshBasicMaterial({ color: 0xe56956 });
const mesh = new THREE.Mesh(geometry, material);
The 0x
in front of the color property value means that it is a hexadecimal value. Most materials require a light source to bounce off of them so that they can be seen. The MeshBasicMaterial
does not.
Now let's add the mesh to the scene:
scene.add(mesh);
Run the code now by pushing the "Run" button at the top of the Replit window. You should see your first scene, an orange cube:
The MeshBasicMaterial
does not look 3D. It would be better to have a material that light can interact with so that we can get some depth to our player box. Replace your material with the following material:
const material = new THREE.MeshLambertMaterial({ color: 0xe56956 });
The MeshLambertMaterial
is a relatively simple material that can reflect light. Your player box will not be visible now, we need to add a light source. We will add an ambient light and a directional light.
Add the following lines to the script.js
file, above the renderer
:
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.6);
directionalLight.position.set(10, 20, 0);
scene.add(ambientLight, directionalLight);
We add a white directional light that is set along the x-, y-, and z-axes so that it shines on the top and side of the box. The ambient light allows us to see the box better from all angles. The first parameter for the light constructor functions is the color, and the second parameter is the light intensity, which ranges from 0 to 1. You should be able to see your player box now. It will look more 3D, as each side has different lighting. You can use your browser dev tools to check the console logs if you encounter any errors.
Let's also get rid of the window scrollbars. Add the following to the style.css
file in the body
selector:
overflow: hidden;
margin: 0;
Your player box should now look like this:
Try changing the material color, light intensity, and camera position to see what happens.
Before we get our player box moving, let's change our code so that it is nicely structured for the game logic that we will add. Add the following lines to the top of the script.js
file:
let camera, scene, renderer, player;
const boxSideLength = 0.5;
init();
The camera
, scene
, and renderer
variables are global variables so that they are available throughout our script. They will be defined in functions we create. Remove the const
keyword in front of their declarations that we already added.
We also initialize a player
variable for our created box.
The boxSideLength
variable is for the x, y and z lengths of our box. Most of our boxes will be squares.
Now let's define the function called init
. This function will be used to initialize the game, by rendering and setting up the scene and creating our objects. Define an init
function and move the camera, light, and renderer code into it:
function init() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(
70,
window.innerWidth / window.innerHeight,
0.1,
200
);
camera.position.set(4, 4, -4);
camera.lookAt(0, 0, 2);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.6);
directionalLight.position.set(10, 20, 0);
scene.add(ambientLight, directionalLight);
initializeBoxes();
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.render(scene, camera);
document.body.appendChild(renderer.domElement);
}
We also call the initializeBoxes
function, which we will soon define.
Now let's make a createBox
function. We will use this function to create our player box, and later to create obstacles. Define the createBox
function and move the code for creating your player box inside of it:
function createBox(x, y, z) {
const geometry = new THREE.BoxGeometry(
boxSideLength,
boxSideLength,
boxSideLength
);
const material = new THREE.MeshLambertMaterial({ color: 0xe56956 });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(x, y, z);
scene.add(mesh);
}
The geometry
definition now uses the boxSideLength
variable to set the lengths of the sides. There is one extra line of code here, the call to the mesh.position.set
function. This will set the position of the created box in the 3D world based on the arguments passed to the function. This will be useful when we create randomly positioned obstacles.
Now let's create an initializeBoxes
function that will be used to create all of the boxes at the start of the game. Add the following lines of code:
function initializeBoxes() {
player = createBox(0, 0, 0);
}
All this function currently does is create a player box that is positioned in the center of our 3D world. Its x, y, and z positions will be 0. If you run your repl code, you should still be able to see your box.
Animating the player box
In the final game, the player box moves continuously along the z-axis. We will make use of an animation loop to continuously move the player box and re-render the scene to see the movement. At the bottom of the script.js
file, add the following lines:
function animate() {
player.mesh.position.z += speed;
camera.position.z += speed;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
The animate
function will be called on every frame by using the requestAnimationFrame
function, which is a web API that is used to create animations. It will call the animate
function before each repaint of the screen by the browser. The number of function calls is usually 60 per second.
For each animate
function call, we move the player box and the camera by changing their position along the z-axis. The camera will follow the player box. Let's add a global speed variable at the top of our script.js
file so that we can easily change it later if we want to:
let speed = 0.10;
To access the player box's mesh
property, let's return the mesh
property from the createBox
function. Add the following to the end of the createBox
function:
return {
mesh,
};
We also need to call our animate
function initially to get it started. In the init
function, add the following line below renderer.render(scene, camera)
:
animate();
Our player box will now be moving, but we won't be able to see the movement. We are going to add a GridHelper
so that we can see the movement. A GridHelper
is an object that defines a grid, which is a two-dimensional array of lines along the x- and y-axes. This will give our 3D world a 2D grid surface. In the init
function, add the following lines below initializeBoxes()
:
const gridHelper = new THREE.GridHelper(200, 200);
scene.add(gridHelper);
You will now be able to see your player box move through the 3D world.
If you wait long enough, the box will move off the grid. Let's restrict the movement of the box so that it can't move off the grid.
Creating a game course
The first thing we will do to prevent the player box from moving off the grid will be to add another box, the finishing line box. The finishing line box will be added at the boundary of the grid. Our game course will be from the center of the grid to the end of the grid, along the z-axis. Later, we will add collision detection to check if our player box has hit the finishing line box so that we can end the game before the player box leaves the grid.
We will create a new global variable called courseLength
that will define the distance to the edge of the grid. Our player box starts moving from the center of the grid (x = 0, y = 0, z = 0) so our square grid's length should be double the course length. We will also restrict the movement of the box along the x- and y-axes. Add the following global variables to the top of the script.js
file:
const courseLength = 100;
const gridHelperSize = courseLength * 2;
// limit movement of player box on x and y-axis
const xBoundary = 4 - (boxSideLength / 2);
const yBoundary = xBoundary / 4;
We set the courseLength
to 100
world units. We use the gridHelperSize
to define the length of our square grid along the x- and z-axes. The xBoundary
and yBoundary
variables are used to limit the movement of our player box along the x- and y-axes.
Let's update the gridHelper
to use the gridHelperSize
variable for its size and number of divisions parameters:
const gridHelper = new THREE.GridHelper(gridHelperSize, gridHelperSize);
Now we will add the finish line box. Add the following to the initializeBoxes
function:
// create finish line box
const geometry = new THREE.BoxGeometry(
xBoundary * 2,
yBoundary * 2,
boxSideLength
);
const material = new THREE.MeshLambertMaterial({ color: "green" });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(0, 0, courseLength);
scene.add(mesh);
This creates a green box that is positioned at the end of the grid. It marks the end of the game course. It's positioned at the end of the course where the z-axis value is equal to the course length. The size of the box along its x- and y-axes marks the x and y boundary. We will restrict the player box's movement so that it can't move past the finish line box. Its size along the x- and y-axes is double the boundary length, because it needs to mark the negative and positive axis boundaries.
You will now be able to see the finish line box at the end of the grid. To reach the end of the grid sooner, you can change the speed
variable.
Controlling the player box
Let's add some controls so that we can move our player box up, down, left, and right. Add the following lines to the bottom of the script.js
file:
// moving player box with arrow keys
window.addEventListener("keydown", (e) => {
const key = e.key;
if (key === "ArrowLeft") {
player.mesh.position.x += speed;
}
if (key === "ArrowRight") {
player.mesh.position.x -= speed;
}
if (key === "ArrowUp") {
player.mesh.position.y += speed;
}
if (key === "ArrowDown") {
player.mesh.position.y -= speed;
}
});
This creates an event listener that listens for a key press event. The player box's mesh (geometry + material) position is increased or decreased along the x- or y-axis, depending on which arrow button is pressed. Run your repl now and you should be able to move your player box with the arrow keys. You will notice that you can move off the screen, and when you reach the edge of the grid, you can avoid hitting the finish line box. Let's restrict the movement of the player box so that it always hits the finish line box. Replace the event listener that you just added with the following code:
window.addEventListener("keydown", (e) => {
const key = e.key;
const currXPos = player.mesh.position.x;
const currYPos = player.mesh.position.y;
if (key === "ArrowLeft") {
if (currXPos > xBoundary) return;
player.mesh.position.x += speed;
}
if (key === "ArrowRight") {
if (currXPos < -xBoundary) return;
player.mesh.position.x -= speed;
}
if (key === "ArrowUp") {
if (currYPos > yBoundary) return;
player.mesh.position.y += speed;
}
if (key === "ArrowDown") {
if (currYPos < -yBoundary) return;
player.mesh.position.y -= speed;
}
});
Here we add some extra lines of code to restrict the movement of the player box along the x- and y-axes by getting the current x and y position of the player box and then preventing movement if the position exceeds the current boundary values that we set using our global variables xBoundary
and yBoundary
.
Note that if you increased the speed variable to reach the end of the course sooner, you may need to increase the width and height of the finish line box to ensure that the player box always hits it, as the player box will be able to move a bit more along the x- and y-axes.
Detecting collisions
To determine that we have reached the end of the course, we need to be able to detect collisions. Once the player box has collided with the finish line box, the game is over. Add the following global variables to the top of your script.js
file:
let gameOver = false;
const numOfObstacles = 0;
var obstaclesBoundingBoxes = [];
The gameOver
flag variable will be used to determine when the game is over. This will occur once the finish line is reached, or if the player box collides with an obstacle (we will add obstacles later). The number of obstacles is currently zero. The obstaclesBoundingBoxes
will store a set of x, y, and z positions of bounding boxes that describe the positions of all of our objects, excluding the player box. We will use these bounding boxes to detect collisions.
In the initializeBoxes
function, add the following lines at the bottom:
const boundingBox = new THREE.Box3().setFromObject(mesh);
obstaclesBoundingBoxes.push(boundingBox);
This will create a bounding box for the finish line box mesh
object. We add this to the obstaclesBoundingBoxes
array. The Box3
object represents a bounding box in 3D space. It describes a set of coordinates, it does not appear in our 3D world. We use the setFromObject
method to calculate the bounding box of the finish line box, using the finish line box's mesh
. Within the bounding box object, there is a max
and min
property that describes the upper and lower x, y, and z boundaries of the box. For example, the bounding box of the finish line box contains the following max
and min
properties:
max: {x: 3.75, y: 0.9375, z: 100.25}
min: {x: -3.75, y: -0.9375, z: 99.75}
Now let's create a function to detect collisions. Add the following function below the createBox
function:
function detectCollisions() {
const playerBox = new THREE.Box3().setFromObject(player.mesh);
// Check each object to detect if there is a collision
for (let i = 0; i < numOfObstacles + 1; i++) {
// an object was hit
if (obstaclesBoundingBoxes[i].intersectsBox(playerBox)) {
gameOver = true;
alert("You win!");
return;
}
}
}
To detect a collision, we first create a bounding box for the player box by creating a Box3
object. We then loop through the obstaclesBoundingBoxes
array and use the intersectsBox
method to check for an intersection – a collision – between the playerBox
and each obstacle. We currently don't have any obstacles, so we can only check for a collision with the finish line box. Given that we will always hit the finish line box, we set gameOver
to true
once it is hit. We will use an alert to let the player know that they have won when they reach the end of the course.
We need to call the detectCollisions
function in the game loop animate
function so that we constantly check for a collision. Change your animate
function so that it is the same as the animate
function below:
function animate() {
if (gameOver) return;
player.mesh.position.z += speed;
camera.position.z += speed;
detectCollisions();
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
The if statement at the start of the animate
function prevents the function from running if the game is over.
Let's also add if (gameOver) return;
to the start of the "keydown" window.addEventListener
callback function to disable the keyboard event if the game is over.
You will now get a "You win!" alert message once you reach the finish line box at the end of the grid.
Creating obstacles
Now let's add some obstacles for the player box to avoid to make it a game. Set the numOfObstacles
global variable to 50. We are going to write a createObstacle
function to generate 50 randomly positioned obstacles. Add the following function below the createBox
function:
function createObstacle() {
const x = THREE.MathUtils.randFloatSpread(xBoundary * 2);
const y = THREE.MathUtils.randFloatSpread(yBoundary * 2);
const z = THREE.MathUtils.randFloat(10, courseLength - boxSideLength);
const obstacle = createBox(x, y, z);
const boundingBox = new THREE.Box3().setFromObject(obstacle.mesh);
obstaclesBoundingBoxes.push(boundingBox);
}
We make use of some math utility functions of the three.js MathUtils
object to get random x, y, and z points along our course. These will be used to randomly position obstacles. We get random x and y points using our x and y boundaries. The randFloatSpread
function takes in a range parameter and returns a random float in the interval [- range / 2, range / 2]. We get a random z value using the randFloat
function. It takes in a low and high parameter and returns a random float in the interval [low, high]. This value is always positive, as our course is positioned on the positive side of the z-axis. The low value starts at 10, as we don't want to place obstacles right in front of or on the player box. The high value is the courseLength
, so that the obstacles can be positioned all along our course. We subtract the boxSideLength
to prevent the obstacles from being placed on the finish line box. We then pass these x, y, and z points to the createBox
function to create an obstacle box. A bounding box is created for each created obstacle, and added to the obstaclesBoundingBoxes
array so that we can detect collisions between our player box and the obstacles.
We need to call the createObstacle
function to create the obstacles. In the initializeBoxes
function, let's add a for loop to create the obstacles:
function initializeBoxes() {
player = createBox(0, 0, 0);
for (let i = 0; i < numOfObstacles; i++) {
createObstacle();
}
...
}
This will create 50 randomly positioned obstacles. Run your repl to see them:
Each time you reload the page, the obstacles will be randomly positioned.
Adding win-or-lose logic
Try to collide with an obstacle, and you will see that the alert message is always "You win!". Let's fix that by adding some win-or-lose logic. In the detectCollisions
function, replace the for loop with the following for loop:
for (let i = 0; i < numOfObstacles + 1; i++) {
// an object was hit
if (obstaclesBoundingBoxes[i].intersectsBox(playerBox)) {
gameOver = true;
if (i !== numOfObstacles) {
alert("You lose");
} else {
// the last box is the finish line box
alert("You win!");
}
return;
}
}
The last object bounding box in the obstaclesBoundingBoxes
array is the finish line box. Knowing this, we can determine when the player box has reached the finish line without hitting an obstacle. If the player box intersects with the last item in the obstaclesBoundingBoxes
array, you win. If the player box intersects with any other item, you lose.
The game is now playable. Try make it to the end of the obstacle course.
Adding restart
There is one big problem with our game: once the game is over we need to refresh the page to play again. That's not a very good user experience. Let's add a "Play" button that will show on page load and when the game is over. It will start the game and reset everything that needs to be reset at the start of a game.
Add the following to the index.html
file inside of the <body>
tag to create a button and a screen that covers the window behind the button:
<div id="play-btn-screen">
<div class="play-btn-container">
<div>Use the up, down, left and right arrow keys to move</div>
<button id="play-btn">Play</button>
</div>
</div>
Add the following to the style.css
file:
#play-btn-screen {
position: absolute;
min-width: 100%;
min-height: 100%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.play-btn-container {
color: white;
text-align: center;
}
#play-btn {
background-color: red;
padding: 1rem 1.5rem;
margin: 0.5rem;
}
This will create a play button in the middle of the screen.
Now let's make the button work. Add the following global variables to the top of the script.js
file:
const playBtnScreen = document.getElementById("play-btn-screen");
const playBtn = playBtnScreen.querySelector("#play-btn");
var allObjs = [];
This gets the play button screen (background cover behind the button) and play button element from the DOM and stores them in variables. The allObjs
array will store all of the created objects. We need this array to clear the scene by removing all of the objects at the start of each game. We do this so that we can place new randomly positioned objects in the scene and not have objects in the scene from previous rounds.
Next, add a new click event listener on the playBtn
. Add the following to the bottom of the script.js
file:
playBtn.addEventListener("click", () => {
allObjs.forEach((obj) => scene.remove(obj));
camera.position.set(4, 4, -4);
camera.lookAt(0, 0, 2);
initializeBoxes();
gameOver = false;
animate();
playBtnScreen.style.visibility = "hidden";
});
The play button is used to start and also re-start the game. The first thing we do when the play button is clicked is to remove all the objects in the scene using the scene.remove
method. We then reset the camera position and call the initializeBoxes
function, which creates and positions all of the objects. We set gameOver
to false so that our animate
function and keydown
event listener will work. We then call the animate
function to start the animation loop, and then we hide the play button screen, which hides the play button as well. We need to make a few more changes in our script.js
file for the play button to work properly:
Delete the
animate()
function call in theinit
function. We now call it when the play button is clicked.Add
allObjs.push(mesh);
in thecreateBox
function abovescene.add(mesh);
. This adds the created box mesh (object) to theallObjs
array.Add the following lines in the
detectCollisions
function belowgameOver = true;
:
playBtnScreen.style.visibility = "visible";
playBtn.focus();
This will make our play button visible at the end of the game and focus the button so that you can easily restart the game by pressing the spacebar or enter key.
- Add the following lines at the start of the
initializeBoxes
function:
// make empty at start of a game
allObjs = [];
obstaclesBoundingBoxes = [];
This clears the allObjs
array so that it only contains objects created in the current game. We also clear the obstaclesBoundingBoxes
as we only want to detect collisions with objects in the current game.
- Add
allObjs.push(mesh);
in theinitializeBoxes
function abovescene.add(mesh);
. This adds the finish line box to theallObjs
array.
Our game is almost complete, all we need to do now is make it mobile-friendly.
Making the game mobile friendly: Adding on-screen arrow buttons
To make the game mobile friendly, we will add up, down, left, and right buttons to the bottom of the screen.
Add the following to the index.html
file inside the <body>
tag, just above the <script>
tag to create the buttons:
<div id="keys">
<div class="keys-container">
<button id="up">
<svg width="30" height="30" viewBox="0 0 10 10">
<g transform="rotate(0, 5,5)">
<path d="M5,4 L7,6 L3,6 L5,4" />
</g>
</svg>
</button>
<button id="left">
<svg width="30" height="30" viewBox="0 0 10 10">
<g transform="rotate(-90, 5,5)">
<path d="M5,4 L7,6 L3,6 L5,4" />
</g>
</svg>
</button>
<button id="down">
<svg width="30" height="30" viewBox="0 0 10 10">
<g transform="rotate(180, 5,5)">
<path d="M5,4 L7,6 L3,6 L5,4" />
</g>
</svg>
</button>
<button id="right">
<svg width="30" height="30" viewBox="0 0 10 10">
<g transform="rotate(90, 5,5)">
<path d="M5,4 L7,6 L3,6 L5,4" />
</g>
</svg>
</button>
</div>
</div>
The up, down, left, and right icons are created using SVGs.
Now let's add some basic styling to our buttons. Add the following to the style.css
file:
#keys {
position: absolute;
min-width: 100%;
min-height: 100%;
display: flex;
align-items: flex-end;
justify-content: center;
}
.keys-container {
display: grid;
grid-template-columns: 50px 50px 50px;
grid-template-rows: auto;
grid-template-areas:
". up ."
"left down right";
grid-gap: 10px;
padding-bottom: 3rem;
}
#keys button {
padding: 0.5rem;
}
#up {
grid-area: up;
}
#down {
grid-area: down;
}
#left {
grid-area: left;
}
#right {
grid-area: right;
}
To see the arrows, click the "Open in a new tab" button in the repl Output tab. This opens the link to the repl in a new tab. You can copy this link to view your repl on your phone or to share it with your friends.
You should now be able to see the screen arrow buttons.
Try clicking a button before pressing "Play". You will notice that you can't click the arrow buttons. This is because the play button screen, which covers the whole screen, has a CSS z-index
property of 1
. Once you click "Play", the play button screen CSS visibility
property is set to hidden, and you will be able to press the arrow buttons.
Now let's add some JavaScript click event listeners and some functions to make our arrow buttons work on desktop and mobile. Our code is going to look quite complex because we use some extra functions and setTimeout
to allow the player box to continuously move when the arrow button is held down. This makes for a better user experience than having to continuously tap or click the button to move in one direction. Add the following global variable to the top of the script.js
file:
const keyBtns = document.querySelectorAll(".keys-container button");
This gets all the key buttons from the DOM and stores them in a variable.
Now add the following to the bottom of the script.js
file:
let timeoutID = 0;
function moveLeft() {
const currXPos = player.mesh.position.x;
if (currXPos > xBoundary) return;
player.mesh.position.x += speed;
clearTimeout(timeoutID);
timeoutID = setTimeout(moveLeft, 50);
}
function moveRight() {
const currXPos = player.mesh.position.x;
if (currXPos < -xBoundary) return;
player.mesh.position.x -= speed;
clearTimeout(timeoutID);
timeoutID = setTimeout(moveRight, 50);
}
function moveUp() {
const currYPos = player.mesh.position.y;
if (currYPos > yBoundary) return;
player.mesh.position.y += speed;
clearTimeout(timeoutID);
timeoutID = setTimeout(moveUp, 50);
}
function moveDown() {
const currYPos = player.mesh.position.y;
if (currYPos < -yBoundary) return;
player.mesh.position.y -= speed;
clearTimeout(timeoutID);
timeoutID = setTimeout(moveDown, 50);
}
function handleKeyDown(e) {
if (gameOver) return;
const { id } = e.currentTarget;
if (id === "left") {
moveLeft();
}
if (id === "right") {
moveRight();
}
if (id === "up") {
moveUp();
}
if (id === "down") {
moveDown();
}
}
// moving box - mobile - using screen btns
keyBtns.forEach((keyBtn) => {
keyBtn.addEventListener("mousedown", handleKeyDown);
keyBtn.addEventListener("touchstart", handleKeyDown);
keyBtn.addEventListener("mouseup", () => {
clearTimeout(timeoutID);
timeoutID = 0;
});
keyBtn.addEventListener("mouseleave", () => {
clearTimeout(timeoutID);
timeoutID = 0;
});
keyBtn.addEventListener("touchend", () => {
clearTimeout(timeoutID);
timeoutID = 0;
});
keyBtn.addEventListener("touchcancel", () => {
clearTimeout(timeoutID);
timeoutID = 0;
});
});
For each key button, we add "mousedown"
and "touchstart"
event listeners. When the button is clicked or touched, the handleKeyDown
function is called. This function determines which button was clicked or touched by checking the ID of the event's currentTarget
property. Different functions are called depending on the ID of the button. For each function that handles the movement in a particular direction, we get the current position of the player box, check if it is within the set boundaries, and increase or decrease its position by the speed
variable value. We then recursively call the function again after 50 ms using setTimeout
so that the movement is continuous when the button is held down.
The setTimeout
function returns a timeoutID
that is a positive integer value. It identifies the timer created by the call to setTimeout()
. This value is passed to clearTimeout()
to cancel the timeout after each recursive function call so that we don't create unnecessary timeouts.
For each key button, we also add "mouseleave"
, "touchend"
, and "touchcancel"
event listeners. These clear the timeouts when the button is not held down anymore.
A little cheat in the game: If you have a touchscreen laptop, pressing the arrow key on your keyboard and on the screen will make it move faster than normal!
Save and run your project. Our game is complete!
Next steps
We learnt the basics of three.js and built a simple 3D game. There are many things that you can do to improve the game. Here are some you might want to try:
- Replace the alert with a nicely styled modal.
- Style the objects, or import or create 3D models. See rendering 3D scenes with three.js for more information.
- Add a nicer surface instead of the grid.
- Add a loading screen while the 3D scene is loading.
- Make the game more challenging by increasing the speed the player box moves at as the game progresses, adding more obstacles, or by making the obstacles move.
-
Add a points system. Change the game logic so that you have to hit the boxes to get points.
- Store the points in local storage.
Add physics to the collisions using cannon-es.
Or for a more advanced challenge: Make a bigger course, try making infinite movement within finite bounds so that you don't use too much of your computer's memory.
You can find our repl below:
Top comments (0)