Sidequest

Scrolling the Ground

In this section we're going to create a seamlessly looping ground using a single image and two draw calls.

How the Scroll Works

To create the scrolling ground effect, we're going to sample the same image twice at different positions.

Scrolling the ground diagram
Scrolling the ground diagram

We're going to track a scrolling position along the x-axis: scrollPositionX. It will constantly be moving to the left by whatever our scrollSpeed is. The difference between this position and 0 will give us a value to leverage for sampling and drawing two portions of the same image. Once scrollPositionX reaches a position less (cause we're moving left - so negative) than the ground width, we reset to 0 and the effect repeats.

Updating the Ground Class

With that explanation in mind, make the following changes to the Ground class:

1
import { SpriteData } from "#/components/sprite-data";
2
import { Vector2d } from "#/components/vector2d";
3
4
type GroundOptions = {
5
position: Vector2d;
6
spriteData: SpriteData;
7
spriteSheet: HTMLImageElement;
8
+
/**
+
* This is in pixels **per frame**.
+
*/
+
scrollSpeed: number;
13
};
14
15
export class Ground {
16
position: Vector2d;
17
spriteData: SpriteData;
18
spriteSheet: HTMLImageElement;
+
scrollSpeed: number;
20
+
// Track the current scroll position separately from the position.
+
scrollPositionX = 0;
23
24
constructor(options: GroundOptions) {
25
this.position = options.position;
26
this.spriteData = options.spriteData;
27
this.spriteSheet = options.spriteSheet;
+
this.scrollSpeed = options.scrollSpeed;
29
}
30
+
public update() {
+
this.scrollPositionX -= this.scrollSpeed;
+
+
// Once the scroll position is less than the width of our ground frame,
+
// reset it back to 0 to create the loop effect.
+
if (this.scrollPositionX <= -this.spriteData.width) {
+
this.scrollPositionX = 0;
+
}
+
}
40
+
public draw(context: CanvasRenderingContext2D) {
+
// scrollPositionX is constantly in the negative direction, so we need to
+
// get the absolute value for the sampling below.
+
const diff = Math.abs(this.scrollPositionX);
+
+
// Draw call for A
+
context.drawImage(
+
this.spriteSheet,
+
this.spriteData.sourceX + diff,
+
this.spriteData.sourceY,
+
this.spriteData.width - diff,
+
this.spriteData.height,
+
this.position.x,
+
this.position.y,
+
this.spriteData.width - diff,
+
this.spriteData.height
+
);
+
+
// Draw call for B
+
context.drawImage(
+
this.spriteSheet,
+
this.spriteData.sourceX,
+
this.spriteData.sourceY,
+
diff,
+
this.spriteData.height,
+
context.canvas.width - diff,
+
this.position.y,
+
diff,
+
this.spriteData.height
+
);
+
}
72
}

The update() and draw() methods need to be called repeatedly to constantly modify scrollPositionX, and render the current state. For that we'll need a game loop.

Before we move on, update the contructor call back in main.ts to set the scrollSpeed:

1
const ground = new Ground({
2
position: new Vector2d(0, config.gameHeight - spriteMap.ground.height),
3
spriteData: spriteMap.ground,
4
spriteSheet,
+
scrollSpeed: 2,
6
});

Game Loop

Games run within a loop, often referred to as the game loop. This loop typically runs on an interval to read input, update game objects, and display (render) the current state of the game to the player. A single iteration of the game loop is also commonly referred to as a frame, and the number of frames we can achieve in a second is our frame rate. Common frame rates might be 30fps and 60fps, where fps means frames per second. These frame rates help dictate our window of time to update and render the game.

Let's break a sample frame rate down. At 60fps, we would have roughly 0.01666 seconds (1 / 60) or about 16.67 ms to process a single frame. That may not seem like a lot of time, but you may be surprised what can be achieved in such a short period on a computer. Flappy Bird isn't a very intense game, so we shouldn't run into any frame rate issues.

Our game loop will make use of requestAnimationFrame(). It takes a callback function which is called before the next repaint in the browser. It also matches your display refresh rate, but it's possible it will be higher. An important thing to remember is requestAnimationFrame() does not give us a constant game loop with a single call, it simply schedules our callback function for the next available opportunity. This is up to the browser. So we need to make sure the callback function itself also calls requestAnimationFrame() to reschedule itself again, giving us a game loop.

Here's a sample game loop:

1
/**
2
* A single iteration of our game loop.
3
*/
4
const frame = () => {
5
// clear canvas
6
// update logic
7
// draw logic
8
9
// Schedule the next frame.
10
requestAnimationFrame(frame);
11
};
12
13
// Start the game loop.
14
requestAnimationFrame(frame);

It's pretty common to group update logic such as moving entities and checking for collisions, and draw logic such as drawing sprites. We want to get the latest state of the game world before drawing, so we'll need to update the game entities before drawing them.

Tying It All Together

We have all the components to render the background and scrolling ground, we just need to fleshout the frame function. Note that we draw the background image first, then the ground. Draw order matters. We're also clearing the canvas before any drawing occurs. We redraw the state of the game each frame.

Let's add the frame() function and make use of update() and draw(). This can all be added to the bottom of the file:

1
const frame = () => {
2
context.clearRect(0, 0, canvas.width, canvas.height);
3
4
ground.update();
5
6
// Draw the background
7
context.drawImage(
8
spriteSheet,
9
spriteMap.background.sourceX,
10
spriteMap.background.sourceY,
11
spriteMap.background.width,
12
spriteMap.background.height,
13
0,
14
0,
15
spriteMap.background.width,
16
spriteMap.background.height
17
);
18
19
ground.draw(context);
20
21
requestAnimationFrame(frame);
22
};
23
24
requestAnimationFrame(frame);

We haven't talked about clearRect() yet. It's a method that clears a rectangular area of the canvas. We're starting at the top left corner of the canvas and clearing the entire canvas.

Clean Up

Now that we're drawing in the game loop, let's remove the original background drawing and ground drawing code outside the frame function.

-
// Draw the background
-
context.drawImage(
-
spriteSheet,
-
spriteMap.background.sourceX,
-
spriteMap.background.sourceY,
-
spriteMap.background.width,
-
spriteMap.background.height,
-
0,
-
0,
-
spriteMap.background.width,
-
spriteMap.background.height
-
);
13
14
const ground = new Ground({
15
position: new Vector2d(0, config.gameHeight - spriteMap.ground.height),
16
spriteData: spriteMap.ground,
17
spriteSheet,
18
});
19
-
ground.draw(context);

Final Observations

You might notice the ground is scrolling quite fast. This is because we're scrolling the ground at 2 pixels per frame. So if your monitor is 60hz, that's 120 pixels per second. If you're at 120hz, that's 240, and so on. This isn't going to scale well. We can't have the game experience differing so drastically due to display hardware.

Reasoning about the speed of objects in the game in pixels per frame isn't a very intuitive way of dealing with change over time. It would be nice to focus on a higher unit of time, like pixels per second.

We'll need to account for the change in time for every frame of the game loop. We'll cover this in the next section.