Making a 3D web runner game #Bonus 1: Various improvements

In this first bonus episode, let’s add misc improvements to our Hyperspeed game!

This article is also available on Medium.

Last time, we finished the core features of our game by talking about sounds and music. This concluded the main part of this series of tutorials – we now have a functioning game!

However, there are still lots of little things we can discuss, which is why I’m going to continue posting a few additional bonus episodes.

In this first bonus tutorial, we’ll work on various improvements for our game. We’ll see how to add some fog in the distance, how to have obstacle collisions trigger a camera shake, how to show little popups when we collect bonuses to better warn the players about the price of the object they just grabbed and how to have the game speed up gradually as it progresses.


This tutorial is available either in video format or in text format — see below 🙂

Adding some fog

Ok – let’s start with the fog!

Thanks to Three.JS built-ins, it is actually pretty simple to add fog in our scene and have the objects that spawn at the horizon be very dimmed.

All we need to do is go to our main.js script and, after we’ve created the scene, use Three.JS’s Fog() function to directly create a new fog with given near and far clip planes. As explained in the official docs, this method lets us create a fade effect for all objects that are within a given distance range from the camera. (Objects that are closer from the camera than the near plane will not be affected, and similarly objects that are further away from the camera than the far plane won’t be affected either.)

In our case, given the dimensions of our grid, we can use something like 120 and 160 for the near and far clip planes to only fade the “just-spawned” objects.

However, you might notice that, at this point, the objects are fading but not the grid! It is still solid white, even at the horizon:

And that’s because: remember that our grid is a static object which position is fixed at 0. In other words, it is far closer to the camera than the 120 near plane we just defined! To fix this, we’ll once again need to improve our grid shader.

The idea is basically to take our vecColor but multiply it by a coefficient that decreases as we look further away in front of us. This way, the colour will fade to black at the horizon. So, first, let’s move our colour declaration after we’ve computed our new pos.z variable; then, let’s use this variable but normalise it using the grid limit (200). Also, don’t forget that the Z axis is pointing backwards from us, so we need to take the opposite of this value:

Now, if I compute this coefficient and apply it to my colour, you see that I have a fading gradient… but in the wrong direction!

For now, the grid gets darker and darker as I get closer to my spaceship, which is the reverse from what we want.

All I need to do is subtract this value from 1 to make sure to get the inverse:

Note: you can also play around with the denominator to have the gradient spread more or less: a higher value will constrain the gradient to the horizon while a smaller value will make it come closer to our position 😉

Ok – we now have a nice little fog effect!

Shaking the camera on obstacle collision

The second improvement I want to make is to add some camera shake whenever I hit an obstacle. This will reinforce the collision effect because for now the player is just warned by a sound, but it’s not that impressive visually.

To begin with, let’s go to our Game class and create a new function, _shakeCamera(), that we’ll call from our _checkCollisions() method if we hit an obstacle:

In this function, I’ll basically want to do a series of small lerps from the camera position to a slightly offset position, pretty quickly, so that it feels like the camera is jumping around for a little while, and then finally reset the camera to its original position to restore the “normal” gameplay.

This means that, first, we need to bake and send this initial position to the function. Let’s add this parameter and pass it in when we call the method from the _checkCollisions() function:

We can’t just pass the this.camera.position because this would pass it by reference and not by value, meaning that the script would grab the latest value for this variable when it needs it, instead of the one it had when we called the function. By passing in an object like this, we make sure that we pass the position the camera had at the beginning of the shake.

Now, back in our _shakeCamera(), we know that we want to make a series of lerps. This means that we will be calling this _shakeCamera() function recursively for a given number of times; then, when we’ve finished our series of small jumps, we’ll exit the “shake” mode and reset the camera.

To do this, let’s define a remainingShakes parameter that is by default set to 3:

Then, let’s create a new Lerp object that, in its onFinish hook, checks to see if there are still some remaining shakes to perform. If there are, it will re-call the shake function with one less shake; else, it will reset the camera position:

The lerp we want to compute is a lerp on the offset: we are going to lerp an object with two keys, x and y, and have those values go from 0 to a random float in the [-0.25, 0.25] range:

As usual, those are values that I have tested and I think work well for my game, but feel free to adapt those to your needs!

We will see in just a second how to update our handmade lerp module to handle objects instead of single float values…

Just before we do that, however, we need to use the onUpdate hook of the lerp object to update the position of the camera: we’ll simply record the start position at the very beginning of the function (so that’s the position the camera had with an offset of zero), and then add the lerped offset to this position on updates:

Of course, we also need to assign this lerp object to an instance variable, for example this.cameraLerp, and declare it in the _reset() function, so that we can change its current time delta in the update() function:

Now, we just have to jump to our lerp.js file and, in the update() method of the class, check whether we’re working with a simple number (like we had for the ship rotation), or with an object. If we have an object, then we’ll lerp all the keys inside.

Note that we expect the from and to objects to have matching keys to perform this computation. So, for example, here, we need to have the x and y in the source and target offsets.

If you save this, you’ll see that, now, when you hit an obstacle, the camera jitters a bit around before resetting to its initial position!

Creating popups on bonus grab

In this third part of the tutorial, we’re going to see how to add popups when we pick bonuses up. This will help the player evaluate how much score points this bonus was worth and add some dynamism to the scene.

Overall, the technique is just to create a temporary div in our DOM that has a little animation to float upwards and disappear gradually. This is pretty easy to do in JavaScript with some of the built-in DOM functions.

I’ll just create a new util method in my Game class, _createScorePopup(), that takes in the score value to display; then, I’ll call this function from my _checkCollisions() method whenever I collide with a bonus:

In this method, I will create a new DOM element using the JS built-in document.createElement(). I can set the inner text and a specific class for this new element, append it to the document and set a little timeout to remove it from the DOM after a second (when its animation is finished):

Now, let’s go to our CSS file and add some animation to objects using this class.

First, we want this popup to be roughly in the center of the screen. For this, we can use absolute positioning and use the left parameter along with a small translate. This re-centers the object so that its pivot point is now in the middle of the label and not in the top-left corner:

But I won’t set the top value directly! Instead, I’ll say that the score-popup class uses an animation that controls both the top value, and the opacity of the HTML element. More precisely, I’ll define two keyframes at 0 and 100% (meaning, the very beginning and the very end of the animation) and say that the top value goes from 33% to 25% – so it moves up a little –, and that the opacity fades from 1 to 0:

Note: for more info on CSS animations, check out this article I posted recently with some util CSS tricks! 😉

I’ll also make sure that this animation plays only once by passing in the animation-iteration-count property. And we can wrap this up by increasing the font-size and setting the colour to white:

Now, if you run your game, you’ll see that when you go over a bonus, you get a little popup in the middle of the screen that tells you the price of the bonus you just grabbed!

Having the game speed up gradually

Finally, let’s make our game slowly speed up as it progresses! This is pretty common in arcade-styled games: the game in itself doesn’t change much throughout the session but it gets harder and harder, either because there are more enemies, or because it just gets faster.

Here, to have the game accelerate, we only need two lines of code!

We simply need to go to our _updateGrid() method and, at the very top, add a small increment to our speedZ value every turn. We also have to re-update this value in the grid shader to that the grid movement is consistent with the objects parent anchor’s:

Be careful though: don’t put too high a value here! Since it happens every turn, if you put a larger value in there, the game will speed up very, very rapidly, and it will be unplayable after a few seconds! 🙂

Conclusion

Today, we’ve made various little improvements to our game: we now have some fog, camera shakes, bonus popups and even a slight speed up of the game every turn.

Next time, the second bonus episode will be about debugging – we will see how to show a little debugging stats panel in our game to easily inspect the FPS and memory consumption of our app…

Leave a Reply

Your email address will not be published. Required fields are marked *