Previously we created a real time loop that sampled the keyboard and drew some rectables in a canvas. We have everything we need to make a simple game.
We will create a limited version of the classic QBasic artillary game called Gorillas in html using canvas. In this game players stand on either end of a skyline and take turns entering a velocity and and angle. This throws an exploding banana that follows a basic projectil motion path in the hopes of hitting the other player. The bananas can also take out chunks of the buildings if it misses the player. The game features a memorable song on the intro screen, wind that affects the trajectory of the banana and a victory dance by the gorillas.
We will create a much
more limited game.
We will only stick the gorillas on a single flat line, draw the gorillas and the bananas using a simple rectangle:
In doing so we will cover:
We can describe the game like so:
We can show this using this state diagram:
Using a procedural programming style we can implement this machine like this (of course this can be implemented much cleaner).
Each of the states in a our diagram we'll track in what we call "runState":
const HANDLE_P1_INPUT = 0,
MOVE_P1_BANANA = 1,
PLAYER_1_WINS = 2,
HANDLE_P2_INPUT = 3,
MOVE_P2_BANANA = 4,
PLAYER_2_WINS = 5;
let gameState = {
runState: HANDLE_P1_INPUT,
...
}
... rest of initialization ...
//called from real-time loop
function loopOnce() {
handleInputs();
updateState();
...
}
function handleInputs() {
if( gameState.runState === HANDLE_P1_INPUT ) {
...
}
if( gameState.runState === HANDLE_P2_INPUT ) {
...
}
}
function updateState() {
if( gameState.runState === MOVE_P1_BANANA ) {
moveBanana();
if( bananaHitPlayer2() ) {
gameState.runState = PLAYER_1_WINS;
}
else {
gameState.runState = HANDLE_P1_INPUT;
}
}
if( gameState.runState === MOVE_P2_BANANA ) {
moveBanana();
if( bananaHitPlayer2() ) {
gameState.runState = PLAYER_2_WINS;
}
else {
gameState.runState = HANDLE_P1_INPUT;
}
}
}
"Game state" is a fancy phrase that means all the things we need to track during our game. Gorillas is a 2D game so any location on the canvas we wish to track will always have an "x" and a "y". We start with a rough outline:
gameState = {
runState: HANDLE_P1_INPUT, //our start state
player1: {x:0, y:0, angle:0, velocity:0},
player2: {x:0, y:0, angle:0, velocity:0},
banana: {x:0, y:0}
};
Animating motion is pretty simple. Move something a little bit and redraw it over and over. The tricky park is figuring out what to move it by. A simple way is to just track how much we intend to change the position's x and y by. The rate that position change is the "velocity" so we will call these components vx and vy. Our "move" function can look something like this:
function move() {
x += vx;
y += vy;
}
Here is a simple implementation showing moving a simple yellow rectangle.
The is neat but a bit boring. What we need is GRAVITY. If we add a bit to vx every frame we can simulate gravity:
We can now move our banana in a convincing manner provided we know what vx and vy to use. The problem is that we don't have vx and vy but rather angle and velocity. In this case "velocity" actually means "speed" and what we have a the polar coordinates of velocity. We can convert to the x and y components using our old friends "sine" and "cosine". JavaScript provides these functions natively in the "Math.cos(...)" and "Math.sin(...)" functions. The problem is that these functions operate in terms of radians which go through 0 to 2pi and we have angles that go from 0 to 360. JavaScript also provides the constant "Math.PI" that makes this a sinch. Converting between these two is just simple scaling:
const TWO_PI = 2*Math.PI;
...
radians = angle/360*TWO_PI
...
These functions always return the x and y components of the unit circle so we have scale by our "speed" or
"velocity" number. The last thing to remember is that in a canvas, +y points "down" towards the bottom
of the screen whereas the sin/cos assume +y is up. The full math is:
const TWO_PI = 2*Math.PI;
...
let radians = angle/360*TWO_PI,
vx = velocity*Math.cos(radians),
vy = -velocity*Math.sin(radians);
Simple! If you were performing this in an environment where you do not have access to a sin/cos function
you would use a look up table. Especially since we are only using whole numbers we can just use a calculator
or another programming environment to generate a lookup table like this:
let sin_table = [];
sin_table[0] = 0.01745240643728351;
sin_table[1] = 0.03489949670250097;
...
However, we don't have to do this because the browser supplies full sin/cos functions and the performance
is more than enough for what we are doing.
Now that we know how we will move our banana, we have to figure out whether or not it hit our gorilla. Intersecting a banana image with a gorilla image is a bit tough:
Instead we will only consider the image's bounding box:
A bounding box can be represented as a rectangle with an x, y and width and height. Our "collision detection" just comes down to figuring out whether 2 rectangles overlap or not. This is much easier. This 2D problem can be broken down into two separate 1D problems. We can consider each of the [x, w] and [y, h] as two seperate number ranges. A 2D rectangle overlaps another 2D rectagle if *BOTH* of it's 1D number ranges overlap.
Lets define a number range to be a start number(n) and some width (w). If we have two of these ranges (n1, w1 and n2, w2) we only have to consider two cases to know whether or not they overlap.
Case 1 - n1 < n2 (ie n1 comes first)

Case 2 - n1 > n2 (ie n1 comes after n2)
For case 1, the ranges overlap if n2 - n1 is less than w1.
For case 2, the ranges overlap if n1 - n2 is less than w2.
This is easy to implement then:
function doRangesOverlap(n1, w1, n2, w2) {
if( n1 < n2 ) {
return n2 - n1 < w1;
}
return n1 - n2 < w2;
}
Now that we have this, implementing the rectagle overlap functions becomes easy enough:
function doRectsOverlap(rect1, rect2) {
return doRangesOverlap(rect1.x, rect1.w, rect2.x, rect2.w) &&
doRangesOverlap(rect1.y, rect1.h, rect2.y, rect2.h);
}
Now we can put it all together:
It's easy to see that much of the code in handling the states HANDLE_P1_INPUT is duplicated to the HANDLE_P2_INPUT and the same goes with MOVE_P1_BANANA and MOVE_P2_BANANA. We can be smarter with our run-states and reduce the states to:
We can introduce a new piece of game state called "currPlayer" and whenever we transition from MOVE_BANANA to HANDLE_INPUT we can do the following:
let gameState = {
...
currPlayer: null, //we have to initialize this!
...
}
function switchPlayer() {
if( gameState.currPlayer === gameState.player1 ) {
gameState.currPlayer = gameState.player2;
} else {
gameState.currPlayer = gameState.player1;
}
}
In general these top level run-states should be broad and used for handling stages, cut scenes,
transitions, title screens, option menus etc. These are the top level states of the game itself.
We could start encoding all kinds of
conditions in the run-states but that quickly explodes the number of states you have to keep track of.
By keeping track of the current player in a seperate variables we pushed down the state
into a lower level of the game. This will be a common pattern. For example, in a fighting game
like Street Fighter, each fighter will maintain it's own little state machine. Although we don't
need to explicitly implement a stat-machine it's useful to think of objects in your game as little
state machines running inside of a larger state machine.