๐ŸŽฎ Game Development: 3D Breakout

Are you a fan of classic games like Breakout, but looking to add a modern twist?
Look no further than @react-three/fiber, a powerful library for creating 3D graphics in React. In this tutorial, weโ€™ll explore how to use @react-three/fiber to build a 3D version of Breakout. Letโ€™s get started and bring Breakout into the world of 3D!
If you are not familiar with the game, you can play it online here or here.

breakout.png

Game design

  1. Render bricks, ball and paddle in 3D. All motion is in 2D.
  2. The ball starts over in a random direction if it misses the paddle.
  3. When the ball strikes sth, it should bounce off with reflected arrival angle.
  4. User can move the paddle to the left and right.
  5. When the ball strikes brick, it disappears.
  6. The game ends when all bricks disappear.

Basic scene

As the official documentation said, the Canvas component does some important setup work behind the scenes: It sets up a Scene and a Camera, the basic building blocks necessary for rendering; And it renders our scene every frame, you do not need a traditional render-loop.
Then add some lights to the scene by putting components into canvas like ambientLight that is directly equivalent to new THREE.AmbientLight().

1
2
3
4
<Canvas shadows camera={{ position: [0, 0, -11], fov: 50 }}>
<ambientLight intensity={0.3} />
<pointLight position={[10, 10, 5]} />
</Canvas>

Create bricks, ball and paddle

The mesh component is a basic scene object in three.js that is directly equivalent to new THREE.Mesh(), and it is used to hold the geometry and materials needed to represent a shape in 3D space. Then we can create a new mesh using a BoxGeometry and a MeshStandardMaterial which automatically attach to their parent.
To build the paddle, we use the boxGeometry component to create a 3D box geometry, and it accepts arguments include the width, height and depth of the box. Same way to build the wall.

1
2
3
4
5
6
7
8
function Paddle({ args = [WIDTH, HEIGHT, DEPTH], color, position }) {
return (
<mesh name="paddle" position={position}>
<boxGeometry args={args} />
<meshStandardMaterial color={color} />
</mesh>
);
}

To build the ball, we use the sphereGeometry component to create a sphere, and it accepts five arguments:

  1. radius: The radius of the sphere (default is 1).
  2. widthSegments: The number of segments along the width of sphere (default 8).
  3. heightSegments: The number of segments along the height of sphere (default 6).
  4. phiStart: The angle at which to start creating sphere (default 0).
  5. phiLength: The angle at which to end creating sphere (default Math.PI * 2).
1
2
3
4
5
6
7
8
function Ball({ args = [RADIUS, 32, 32], color, position }) {
return (
<mesh name="ball" position={position}>
<sphereGeometry args={args} />
<meshStandardMaterial color={color} />
</mesh>
);
}

Interaction

The user can move the paddle left and right.
Unlike traditional game rules, the position of the paddle will be manipulated using a change of mouse position instead of using keyboard keys. Keeps the mouse offset a multiple of the paddle position when the user changes the position of the 3D camera by calculating the distance from the 3D camera to the paddle. And limit the paddle position interval according to the position of the left and right walls.

1
2
3
4
5
6
7
const ref = useRef();
const { camera } = useThree();
useFrame((state) => {
const distance = ref.current.position.distanceTo(camera.position);
ref.current.position.x = Math.max(minX, Math.min(maxX, -state.mouse.x * distance));
ref.current.position.y = y;
});

3D collision detection

Using AABB

AABB stands for Axis-Aligned Bounding Box.
To test whether a sphere and an AABB (Axis-Aligned Bounding Box) are colliding, we can use the Separating Axis Theorem (SAT). This theorem states that if two convex shapes are not colliding, then there must exist an axis along which the two shapes are separated.
First check if the sphere is within the AABBโ€™s boundaries. If it is, then the two shapes are colliding. If not, then we can use the SAT to test for a collision. To do this, we need to check if the sphere is separated from the AABB along any of the three axes (x, y, and z). If the sphere is not separated from the AABB along any of the axes, then the two shapes are colliding.

1
2
3
4
5
6
7
8
9
10
11
function intersect(sphere, box) {
const x = Math.max(box.minX, Math.min(sphere.x, box.maxX));
const y = Math.max(box.minY, Math.min(sphere.y, box.maxY));
const z = Math.max(box.minZ, Math.min(sphere.z, box.maxZ));
const distance = Math.sqrt(
(x - sphere.x) * (x - sphere.x) +
(y - sphere.y) * (y - sphere.y) +
(z - sphere.z) * (z - sphere.z)
);
return distance < sphere.radius;
}

Using Box3 and Sphere

We can use the Box3.intersectsSphere() or Sphere.intersectsBox() method and it returns a boolean value indicating whether the two objects are intersecting.

1
2
3
const box = new THREE.Box3(new THREE.Vector3(), new THREE.Vector3());
const sphere = new THREE.Sphere(new THREE.Vector3(), 1);
const isIntersecting = box.intersectsSphere(sphere); // or sphere.intersectsBox(box);

Using Raycaster

Create a Raycaster object and set its origin to the center of the sphere and its direction to the center of the box. Then create a Box3 object and set its min and max points to the boxโ€™s corner points. Finally, call the Raycasterโ€˜s intersectObject() method passing in the Box3 object as an argument. If the method returns a non-null value, then the sphere and the box are colliding.

1
2
3
4
5
const { scene } = useThree();
const raycaster = new THREE.Raycaster();
raycaster.set(position, ballDirection);
const intersects = raycaster.intersectObjects(scene.children);
if (intersects.length > 0) {} // ball collisions

Possible problem: The main limitation of using Raycaster to test for collisions between a sphere and a box in three.js is that it is not very accurate. Raycaster can only detect collisions between two objects if the ray intersects with the surface of the object. This means that if the sphere is slightly offset from the box, the ray may not intersect with the surface of the sphere and the collision will not be detected. Additionally, Raycaster is not able to detect collisions between two objects that are moving, as the ray must be cast from a static point.

Animate the ball

Define speed as a constant and ballDirection as the direction of the ballโ€™s motion on x and y. The useState hook is used to create a new Vector3 object with the initial position of (0, 0, 0). The useFrame hook is used to update the position of the Vector3 object.

1
2
3
4
5
6
const [position, setPosition] = useState(new Vector3(0, 0, 0));
useFrame(() => {
const { x, y } = ballDirection;
const newPosition = new Vector3(position.x + x * speed, position.y + y * speed, 0);
setPosition(newPosition);
})

Randomness in bounces

When the ball hits a wall, brick or racket, it should bounce off at a reflected angle of arrival, like Specular as follows. But there is a limitation that the ball will always bounce off the walls and blocks at the same angle, no matter where it hits. The trajectory of the ball is fixed, and there will be positions that the ball cannot reach, making the game uninteresting.

1
2
3
4
5
6
7
8
9
10
11
12
const { x, y } = ballDirection;
if (distance < RADIUS && intersectedObject) {
if (intersectedObject === "paddle")
setBallDirection(new Vector3(x, y * -1, 0));
if (intersectedObject === "topWall")
setBallDirection(new Vector3(x, y * -1, 0));
else if (["leftWall", "rightWall"].includes(intersectedObject))
setBallDirection(new Vector3(x * -1, y, 0));
else if (intersectedObject === "brick") {
setBallDirection(new Vector3(x * -1, y * -1, 0));
}
}

For a better physics experience, randomness can be added to the calculation of the reflection angle of the ball. Here is an example:

1
2
3
4
5
6
7
8
9
10
11
const getFlag = n => n > 0 ? 1 : -1;
const randomReflect = (xv, yv, from, to) => {
let xFlag = getFlag(xv);
let yFlag = getFlag(yv);
let angle = Math.random() * (to - from) + from;
let x = Math.cos(angle * Math.PI / 180);
let y = Math.sin(angle * Math.PI / 180);
return new Vector3(Math.abs(x) * xFlag, Math.abs(y) * yFlag, 0);
}
const randomLRWallReflect = (xv, yv) => randomReflect(xv, yv, -60, 60);
const randomTBWallReflect = (xv, yv) => randomReflect(xv, yv, 30, 150);

The ball should bounce off the walls by reversing its x-velocity. For example, if the ball is moving to the right, when it collides with the left wall, its x-velocity should be reversed and it should start moving to the left. The same applies for the right wall.
On this basis, add the randomness of the angle with a range limitation.

1
2
3
if (["leftWall", "rightWall"].includes(intersectedObject)) {
setBallDirection(randomLRWallReflect(x * -1, y));
}

Disappearing bricks

The brick disappears when the ball hits it. The game ends when all the bricks disappear.
Remove the object from a scene by scene.remove(). It takes a single argument, which is the object to be removed. It removes the object from the scene and all of its associated properties, such as its position, rotation, scale, and any other properties that were set on the object.

1
2
3
const { scene } = useThree();
const bricks = intersects.filter(i => i.object.name === "brick" && i.distance < RADIUS);
bricks.forEach(i => scene.remove(i.object));

Track score

When the ball collides with bricks, update the score according to the number of bricks.

1
2
const [score, setScore] = useState(0);
const updateScore = (count) => setScore(score + count);

Add music

On game events play a sound, e.g. on ball collisions. I selected free sound samples from here. We are going to add sound effects for: when the ball hitting the paddle, and when the ball hitting a brick.
Create a new Audio() object and set the source to the file that want to play, then call the play() method on the Audio() object to begin playing the sound.
For example:

1
2
3
4
5
6
7
const sound = new Audio('sound.wav');
const playSound = () => {
sound.currentTime = 0;
sound.volume = 1;
sound.play();
}
playSound();

Possible problem: play() failed because the user didnโ€˜t interact with the document. My solution was to add a start button.

3D view control

The OrbitControls component provides a convenient way to interact with a 3D scene without having to manually update the camera position. With it we can control the 3D camera which allows for panning, zooming, and rotating around a target point in all directions by mouse and touch events.

1
<OrbitControls autoRotateSpeed={0.85} zoomSpeed={0.75} minPolarAngle={1} maxPolarAngle={Math.PI} />

OrbitControls.png


References:

๐Ÿ” Check out my code in github.
๐Ÿ“ฎ If find any errors, please feel free to discuss and correct them: biqingsue@[google email].com.