From our sponsor: Meco is a distraction-free space for reading and discovering newsletters, separate from the inbox.
If you like cute little games you will love Karim Maaloul’s “The Aviator” — as a pilot you steer your aircraft across a round little ocean world, evading red “enemies” and collecting blue energy tokens to avoid crashing into the water. It runs entirely in the browser so make sure to quickly play a round to better understand what we are about to do in this tutorial.
By the way, Karim co-founded the Belgian creative agency Epic. His style is unique in its adorableness and his animation craftmanship is astonishing as you can also see in his series of WebGL experiments.
Karim thankfully wrote about the making of and open sourced the code and while it is a fun little game there is still a lot of potential to get even more out of it. In this article we will explore some hands-on changes on how to bring the most fun based on the foundation we have here, a small browser game using Three.js.
This tutorial requires some knowledge of JavaScript and Three.js.
What makes a game fun?
While there obviously is no definitive recipe there are a few key mechanics that will maximize your chances of generating fun. There is a great compilation on gamedesigning.org, so let’s see which items apply already:
✅ Great controls
✅ An interesting theme and visual style
🚫 Excellent sound and music
🚫 Captivating worlds
🤔 Fun gameplay
🚫 Solid level design
🚫 An entertaining story & memorable characters
🤔 Good balance of challenge and reward
✅ Something different
We can see there’s lots to do, too much for a single article of course, so we will get to the general game layout, story, characters and balance later. Now we will improve the gameplay and add sounds — let’s go!
Adding weapons
Guns are always fun! Some games like Space Invaders consist entirely of shooting and it is a great mechanic to add visual excitement, cool sound effects and an extra dimension to the skill requirement so we not only have the up and down movement of the aircraft.
Let’s try some simple gun designs:
These 3D models consist of only 2–3 cylinders of shiny metal material:
const metalMaterial = new THREE.MeshStandardMaterial({
color: 0x222222,
flatShading: true,
roughness: 0.5,
metalness: 1.0
})
class SimpleGun {
static createMesh() {
const BODY_RADIUS = 3
const BODY_LENGTH = 20
const full = new THREE.Group()
const body = new THREE.Mesh(
new THREE.CylinderGeometry(BODY_RADIUS, BODY_RADIUS, BODY_LENGTH),
metalMaterial,
)
body.rotation.z = Math.PI/2
full.add(body)
const barrel = new THREE.Mesh(
new THREE.CylinderGeometry(BODY_RADIUS/2, BODY_RADIUS/2, BODY_LENGTH),
metalMaterial,
)
barrel.rotation.z = Math.PI/2
barrel.position.x = BODY_LENGTH
full.add(barrel)
return full
}
}
We will have 3 guns: A SimpleGun, then the DoubleGun as just two of those and then the BetterGun which has just a bit different proportions and another cylinder at the tip.
Positioning the guns on the plane was done by simply experimenting with the positional x/y/z values.
The shooting mechanic itself is straight forward:
class SimpleGun {
downtime() {
return 0.1
}
damage() {
return 1
}
shoot(direction) {
const BULLET_SPEED = 0.5
const RECOIL_DISTANCE = 4
const RECOIL_DURATION = this.downtime() / 1.5
const position = new THREE.Vector3()
this.mesh.getWorldPosition(position)
position.add(new THREE.Vector3(5, 0, 0))
spawnProjectile(this.damage(), position, direction, BULLET_SPEED, 0.3, 3)
// Little explosion at exhaust
spawnParticles(position.clone().add(new THREE.Vector3(2,0,0)), 1, Colors.orange, 0.2)
// Recoil of gun
const initialX = this.mesh.position.x
TweenMax.to(this.mesh.position, {
duration: RECOIL_DURATION,
x: initialX - RECOIL_DISTANCE,
onComplete: () => {
TweenMax.to(this.mesh.position, {
duration: RECOIL_DURATION,
x: initialX,
})
},
})
}
}
class Airplane {
shoot() {
if (!this.weapon) {
return
}
// rate-limit shooting
const nowTime = new Date().getTime() / 1000
if (nowTime-this.lastShot < this.weapon.downtime()) {
return
}
this.lastShot = nowTime
// fire the shot
let direction = new THREE.Vector3(10, 0, 0)
direction.applyEuler(airplane.mesh.rotation)
this.weapon.shoot(direction)
// recoil airplane
const recoilForce = this.weapon.damage()
TweenMax.to(this.mesh.position, {
duration: 0.05,
x: this.mesh.position.x - recoilForce,
})
}
}
// in the main loop
if (mouseDown[0] || keysDown['Space']) {
airplane.shoot()
}
Now the collision detection with the enemies, we just check whether the enemy’s bounding box intersects with the bullet’s box:
class Enemy {
tick(deltaTime) {
...
const thisAabb = new THREE.Box3().setFromObject(this.mesh)
for (const projectile of allProjectiles) {
const projectileAabb = new THREE.Box3().setFromObject(projectile.mesh)
if (thisAabb.intersectsBox(projectileAabb)) {
spawnParticles(projectile.mesh.position.clone(), 5, Colors.brownDark, 1)
projectile.remove()
this.hitpoints -= projectile.damage
}
}
if (this.hitpoints <= 0) {
this.explode()
}
}
explode() {
spawnParticles(this.mesh.position.clone(), 15, Colors.red, 3)
sceneManager.remove(this)
}
}
Et voilá, we can shoot with different weapons and it’s super fun!
Changing the energy system to lives and coins
Currently the game features an energy/fuel bar that slowly drains over time and fills up when collecting the blue pills. I feel like this makes sense but a more conventional system of having lives as health, symbolized by hearts, and coins as goodies is clearer to players and will allow for more flexibility in the gameplay.
In the code the change from blue pills to golden coins is easy: We changed the color and then the geometry from THREE.TetrahedronGeometry(5,0)
to THREE.CylinderGeometry(4, 4, 1, 10)
.
The new logic now is: We start out with three lives and whenever our airplane crashes into an enemy we lose one. The amount of collected coins show in the interface. The coins don’t yet have real impact on the gameplay but they are great for the score board and we can easily add some mechanics later: For example that the player can buy accessoires for the airplane with their coins, having a lifetime coin counter or we could design a game mode where the task is to not miss a single coin on the map.
Adding sounds
This is an obvious improvement and conceptually simple — we just need to find fitting, free sound bites and integrate them.
Luckily on https://freesound.org and https://www.zapsplat.com/ we can search for sound effects and use them freely, just make sure to attribute where required.
Example of a gun shot sound: https://freesound.org/people/LeMudCrab/sounds/163456/.
We load all 24 sound files at the start of the game and then to play a sound we code a simple audioManager.play(‘shot-soft’)
. Repetitively playing the same sound can get boring for the ears when shooting for a few seconds or when collecting a few coins in a row, so we make sure to have several different sounds for those and just select randomly which one to play.
Be aware though that browsers require a page interaction, so basically a mouse click, before they allow a website to play sound. This is to prevent websites from annoyingly auto-playing sounds directly after loading. We can simply require a click on a “Start” button after page load to work around this.
Adding collectibles
How do we get the weapons or new lives to the player? We will spawn “collectibles” for that, which is the item (a heart or gun) floating in a bubble that the player can catch.
We already have the spawning logic in the game, for coins and enemies, so we can adopt that easily.
class Collectible {
constructor(mesh, onApply) {
this.mesh = new THREE.Object3D()
const bubble = new THREE.Mesh(
new THREE.SphereGeometry(10, 100, 100),
new THREE.MeshPhongMaterial({
color: COLOR_COLLECTIBLE_BUBBLE,
transparent: true,
opacity: .4,
flatShading: true,
})
)
this.mesh.add(bubble)
this.mesh.add(mesh)
...
}
tick(deltaTime) {
rotateAroundSea(this, deltaTime, world.collectiblesSpeed)
// rotate collectible for visual effect
this.mesh.rotation.y += deltaTime * 0.002 * Math.random()
this.mesh.rotation.z += deltaTime * 0.002 * Math.random()
// collision?
if (utils.collide(airplane.mesh, this.mesh, world.collectibleDistanceTolerance)) {
this.onApply()
this.explode()
}
// passed-by?
else if (this.angle > Math.PI) {
sceneManager.remove(this)
}
}
explode() {
spawnParticles(this.mesh.position.clone(), 15, COLOR_COLLECTIBLE_BUBBLE, 3)
sceneManager.remove(this)
audioManager.play('bubble')
// animation to make it very obvious that we collected this item
TweenMax.to(...)
}
}
function spawnSimpleGunCollectible() {
const gun = SimpleGun.createMesh()
gun.scale.set(0.25, 0.25, 0.25)
gun.position.x = -2
new Collectible(gun, () => {
airplane.equipWeapon(new SimpleGun())
})
}
And that’s it, we have our collectibles:
The only problem is that I couldn’t for the life of me create a heart model from the three.js primitives so I resorted to a free, low-poly 3D model from the platform cgtrader.
Defining the spawn-logic on the map in a way to have a good balance of challenge and reward requires sensible refining so after some experimenting this felt nice: Spawn the three weapons after a distance of 550, 1150 and 1750 respectively and spawn a life a short while after losing one.
Some more polish
- The ocean’s color gets darker as we progress through the levels
- Show more prominently when we enter a new level
- Show an end game screen after 5 levels
- Adjusted the code for a newer version of the Three.js library
- Tweaked the color theme
More, more, more fun!
We went from a simple fly-up-and-down gameplay to being able to collect guns and shoot the enemies. The sounds add to the atmosphere and the coins mechanics sets us up for new features later on.
Make sure to play our result here! Collect the weapons, have fun with the guns and try to survive until the end of level 5.
If you are interested in the source code, you find it here on GitHub.
How to proceed from here? We improved on some key mechanics and have a proper basis but this is not quite a finalized, polished game yet.
As a next step we plan to dive more into game design theory. We will look at several of the most popular games of the endless runner genre to get insights into their structure and mechanics and how they keep their players engaged. The aim would be to learn more about the advanced concepts and build them into The Aviator.
Stay tuned, so long!