Writing a multiplayer game with HTML5’s Canvas, node.js, and Clay.io

     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).

  1. Getting a game up and running in Canvas
  2. Making the game playable on all devices
  3. 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:


    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:

  1. box2d uses real life units, so you have to scale everything to run in a world of size ~10×6, otherwise box2d loses resolution
  2. there seemed to be problems with resolving collisions: the ball often “dipped” into the slime before bouncing away. 
  3. despite my constant tweaks, I couldn’t get the game to “feel” like the original.
  4. 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 [email protected] # 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.

  • Bikini Lingerie
  • Max

    But where I lived so far, I did not know Slime Volleyball.P.S.: great post, I look forward to reading the next ones : )

  • nthx17

    Holy Mother of … the last part of code you present `if @ball.x > @pole.x ….` needs an Object Oriented rewrite… And you dare to declare u use CoffeeScript ;-)make it a class.. make few methods and make it look readable… a hint: ;)class Ball#….if ball.doesSomethingCool() pole.increaseVelotity()else if ball.doesSomethingEvenCooler() pole.increaseVelocityMore()#….PS. Anyhow, thanks for sharing your experience with box2d :-)

  • Austin Hallock

    We actually updated the AI a while ago (even before your comment), and just hadn’t updated the post. I went in again to look at the code to possible do a rewrite of that bit to make it a bit more structured, but after looking at it for a bit decided to focus my time elsewhere. It’s definitely not the prettiest thing in the world because we tweaked it so much to make it just right.

  • Mike

    I’ve looked at the source for Slime Volley and it’s pretty well-documented but I wish Part 3 (Networking and Multiplayer) of this tutorial was available on your blog. I know there is a YouTube video by the developer of a game called Rawket on this. Can you recommend any similar resources?