In case you’ve been living in a cave for the last few years, you should know that HTML5’s <canvas> is amazing. So amazing, in fact, that we at clay.io have decided to build a company around it. But that’s another story. Today, in the first chapter of a three-part tutorial, I’ll talk about how we used the <canvas> element to produce a cross-platform game with realtime multiplayer (demo available here, source code is on Github).
- Getting a game up and running in Canvas
- Making the game playable on all devices
- Networking and Multiplayer
For my first stab at a Canvas game, I decided to port the popular Java applet Slime Volleyball. I chose this game mostly because it was an old favorite of mine from middle school, and no one had ported it to canvas yet (a tragedy!). Luckily a friend of mine had ported the game to iPhone a while back, and agreed to let me use his awesome graphics:

Having never written a javascript game before, I started out hesitantly by picking my tools. For starters I would need Coffeescript, for its easy object-oriented extensions to javascript and clean Ruby-esque syntax. I also wanted a physics library (I am a lazy programmer), so I settled on a javascript port of the popular box2d library.
Next, I had to decide how to organize my code. I knew I wanted to share as much code as possible between the client and server, so that during network play the server would actually *run* the game to make synchronization easier and prevent cheating. After refactoring a few times, I eventually settled on a simple folder structure:
slime/
src/
client/
manifest.js
server/
manifest.js
shared/
manifest.js
build/
client/
server/
game.js
game.min.js
shared/
Rakefile
index.html
Because I was unhappy with the concatenation and scope options provided by the coffeescript’s built-in `watch` command, I implemented a Rakefile that parses the manifest.js files in every source folder and compiles the files listed in the correct order. The Rakefile allows me to:
$ rake server Compiled server successfully. $ rake client Compiled client successfully. $ rake watch Watching for client changes in ./src/client Watching for server changes in ./src/server Watching for shared changes in ./src/shared
* Currently I am using ruby-1.8.7-p290 with the fssm and coffee gems
Now I was all set up and ready to start! Immediately I ran into problems with box2d. There were so many, in fact, that I’ll make a list:
- box2d uses real life units, so you have to scale everything to run in a world of size ~10×6, otherwise box2d loses resolution
- there seemed to be problems with resolving collisions: the ball often “dipped” into the slime before bouncing away.
- despite my constant tweaks, I couldn’t get the game to “feel” like the original.
- box2d is not deterministic, which would be a nice feature for networking and would allow for easy synchronization as well as client-side prediction.
There didn’t appear to be anything I could do about these issues, so after struggling with it for a week or so, late one night I completely removed box2d and rolled my own. My advice: unless your game is completely physics based, just roll it yourself.
For the rest of the game, I wrote a simple, minimal graphics library consisting of Scene, SceneManager, Sprite, and Button classes. After that I built an asset pre-loader and completed the single-player component of the game. For the AI’s “brains”, I simply ported Skyler’s code:
if @ball.x > @pole.x && @ball.y < 200 && @ball.y > 150 && @p2.velocity.y == 0
@p2.velocity.y = 12
if @ball.x > @pole.x - @p1.width && @ball.x < @p2.x
@p2.x -= (Constants.MOVEMENT_SPEED*.75) + (Constants.MOVEMENT_SPEED*Constants.AI_DIFFICULTY)
if @ball.x > @pole.x - @p1.width && @ball.x + @ball.width + (@ball.velocity.x * Constants.AI_DIFFICULTY) >
@p2.x + @p2.width && @ball.x + @ball.width < @width
@p2.x += (Constants.MOVEMENT_SPEED*.75) + (Constants.MOVEMENT_SPEED*Constants.AI_DIFFICULTY)
else if @ball.x > @pole.x - @p1.width && @ball.x + @ball.width + (@ball.velocity.x * Constants.AI_DIFFICULTY) >
@p2.x + @p2.width && @ball.x + @ball.width >= @width
@p2.x -= (Constants.MOVEMENT_SPEED*.75) + (Constants.MOVEMENT_SPEED*Constants.AI_DIFFICULTY)
if @ball.x + @ball.radius > @p2.x + 30 && @ball.x + @ball.radius < @p2.x + 34
@p2.x += (Constants.MOVEMENT_SPEED*.75) + (Constants.MOVEMENT_SPEED*Constants.AI_DIFFICULTY)
EDIT (04/23/12): We updated the AI some as it wasn’t predicting where the ball was going to land before, and didn’t have randomness added in. Here’s the updated code, though it may not be for the faint of heart:
moveCPU: -> # implement a basic AI
return if @freezeGame
# Predict where the ball is going to end up
# Clone the ball obj
ball = {
x: @ball.x
y: @ball.y
velocity: {
x: @ball.velocity.x
y: @ball.velocity.y
}
acceleration: {
x: @ball.acceleration.x
y: @ball.acceleration.y
}
}
floor = @height - Constants.BOTTOM
while ball.y < floor - @p2.height # predicting the position where will be at slime height
# switch vel if hits wall
if ( ball.x > @width && ball.velocity.x > 0 ) || ( ball.x < 0 && ball.velocity.x < 0 )
ball.velocity.x *= -1
ball.velocity.y = Helpers.yFromAngle(180-ball.velocity.x/ball.velocity.y) * ball.velocity.y
ball.velocity.x = 1 if Math.abs(ball.velocity.x) <= 0.1
ball.x += ball.velocity.x * Constants.FPS_RATIO
ball.y += ball.velocity.y * Constants.FPS_RATIO
ball.velocity.y += ball.acceleration.y * @ball.mass * Constants.FPS_RATIO
if ball.y + ball.height >= floor # ball on ground
ball.y = @height - Constants.BOTTOM - ball.height
ball.velocity.y = 0
ballPos = @ball.x + @ball.radius
p2Pos = @p2.x + @p2.width / 2
pastP1 = @ball.x > @p1.x + @p1.width / 2 + @ball.radius
pastPole = ball.x > @pole.x
ballLand = ball.x + @ball.radius
# Angle between current pos, and land
ballAngle = Math.atan2( ballLand - ballPos, @ball.y )
# Where he wants to be to hit it (based on the angle of the ball and distance from pole)
# More weight is on the angle than the distance
# the randomness makes him stupider
if !@sweetSpot # only generated after collisions
angle = Math.atan2( ball.velocity.x, ball.velocity.y )
sign = if ball.velocity.x < 0 then -1 else 1
# randomness to change up the game
randomOffset = 3 * Math.random() * sign
# Random chance to fail .1 * .5 =
if Math.random() < 0.5 - ( 0.48 * Constants.AI_DIFFICULTY ) # highest difficulty = 2% fail, lowest = 50% fail
randomOffset += ( .75 + Math.random() * .25 ) * 27 * ( 1.7 - Constants.AI_DIFFICULTY * .7) # highest difficulty = ~28, lowest = ~48, mid = ~38
offset = Math.atan( angle ) * @p2.height
offset -= randomOffset
# Distance from net, further it is, the lower we should angle
offset -= 10 * ( ( ballLand - @pole.x ) / ( @width / 2 ) )
# Angle the ball comes in at
offset -= 8 * Constants.AI_DIFFICULTY + .2 * ( 1.57 - Math.abs angle )
@sweetSpot = ballLand - offset
sweetSpot = @sweetSpot
# jump only if angle is steep enough, or ball will land past
if( pastPole && Math.abs( ballPos - ballLand ) < 5 && Math.abs( p2Pos - sweetSpot ) <= 6 && @ball.y < 300 && @ball.y > 50 && @p2.velocity.y == 0 && ballAngle > -1.2 && ballAngle < 1.2 )
@p2.velocity.y = -8 # jump
# ball will pass p2
if sweetSpot > p2Pos + 5
@p2.x += (Constants.MOVEMENT_SPEED*.55) + (Constants.MOVEMENT_SPEED*Constants.AI_DIFFICULTY*.5)
# Ball past 1 and will land past net OR ball heading toward p1 from our side
else if ( ( pastP1 && pastPole ) || ( @ball.velocity.x < 0 && @ball.x > @pole.x ) ) && sweetSpot < p2Pos - 5
@p2.x -= (Constants.MOVEMENT_SPEED*.55) + (Constants.MOVEMENT_SPEED*Constants.AI_DIFFICULTY*.7)
If anyone wants to play around with this code in order to optimize it or produce a frustratingly good AI, feel free to post your results in the comments or submit a pull request on Github.
Next week I’ll talk about making the game cross-platform, which involves the ability to “stretch” to different resolutions, handling multiple methods of input, plus the browser-specific shims we needed to get it all working.