Simple pong game using HTML5 and canvas
Feb 7, 2013I've spend some time working on a simple game using canvas 2d. I had some problems while doing this (some hard to find) and I thought it would be great to share my experiences on this work.
This is a simple version of the well know game pong. Throughout this article I'll give some tips on how I treated the user's input (both keyboard and touch events), a word about collision detection and also troubleshooting some issues that came to me.
According to wikipedia:
Pong is a two-dimensional sports game that simulates table tennis. The player controls an in-game paddle by moving it vertically across the left side of the screen, and can compete against either a computer controlled opponent or another player controlling a second paddle on the opposing side. Players use the paddles to hit a ball back and forth. The aim is for a player to earn more points than the opponent; points are earned when one fails to return the ball to the other.
Introduction
I've used a basic object-oriented approach on top of JavaScript found on Mozilla developer network. In fact, that's not the only way of using object-oriented programming in JavaScript.
In the game we have a MainLoop() function which calls the game update and draw functions (in that order). The main loop is repeatedly called through a setTimeout.
The Game class* is the actual game. It controls each player movement, listens to user input and also call the draw methods.
*Although JavaScript doesn't actually have classes I'll use the term for a better understanding.
Setting the basics
First we need to create a HTML file with a canvas element and source the JavaScript.
<!DOCTYPE HTML> <html> <head> <title>Pong</title> <script defer src="pong.js"></script> <link rel="stylesheet" href="style.css" /> </head> <body> <canvas id="game" width="512" height="256"></canvas> </body> </html>
There's a HTML5 page (you can tell by the first line) with a 512x256 canvas element. The defer keyword in the script tag tells the browser to only execute the script after all elements are loaded. This way we avoid referring to a canvas element that weren't created yet.
The background is painted using CSS instead of calling a canvas draw function to do the job. The reason is that painting on canvas is quite expensive and the browser can do some performance tuning on CSS. So painting a background onto a canvas should be avoided.
#game { background-color: #353535; }
Game mockup
As we discussed before there's a Game which has two main functions: update() and draw().
function Game() { var canvas = document.getElementById("game"); this.width = canvas.width; this.height = canvas.height; this.context = canvas.getContext("2d"); this.context.fillStyle = "white"; }
In the constructor we get the canvas element and a 2d context out of it. We don't actually need to save the canvas element itself because we will rarely use it and we can always get it back from the context using context.canvas.
The other Game methods are implemented below:
Game.prototype.draw = function() { this.context.clearRect(0, 0, this.width, this.height); this.context.fillRect(this.width/2, 0, 2, this.height); }; Game.prototype.update = function() { if (this.paused) return; };
Structuring the main loop
Our game main loop is a function which is called every 33ms. That function has a simple job: call the game update() and draw() functions. To call a function continuously we could use either setInterval, setTimeout or the new requestAnimationFrame.
// Initialize our game instance var game = new Game(); function MainLoop() { game.update(); game.draw(); // Call the main loop again at a frame rate of 30fps setTimeout(MainLoop, 33.3333); } // Start the game execution MainLoop();
For now our game looks like this. A gray rectangle with a white net in the middle.
The players
There are two players here, each on a side of the field. To represent a player we create the Paddle class to store its position, score and size.
function Paddle(x,y) { this.x = x; this.y = y; this.width = 2; this.height = 28; this.score = 0; }
We better create a draw function for the player. Thereby Game don't need to worry on how the player is drawn.
Paddle.prototype.draw = function(p) { p.fillRect(this.x, this.y, this.width, this.height); };
Finally we put the players on each side of the canvas. The 5 pixels margin is purely aesthetic.
function Game() { var canvas = document.getElementById("game"); this.width = canvas.width; this.height = canvas.height; this.context = canvas.getContext("2d"); this.context.fillStyle = "white"; this.p1 = new Paddle(5, 0); this.p1.y = this.height/2 - this.p1.height/2; this.p2 = new Paddle(this.width - 5 - 2, 0); this.p2.y = this.height/2 - this.p2.height/2; }
Game.prototype.draw = function() { this.context.clearRect(0, 0, this.width, this.height); this.context.fillRect(this.width/2, 0, 2, this.height); this.p1.draw(this.context); this.p2.draw(this.context); };
And here's how it's looking like:
Keyboard input
The players only move vertically so we assign W and S to move the left player and UP and DOWN to the right one.
For this purpose we should listen to the keydown event and every time it occurs we'll check the event's keyCode to execute the movement.
If the user keeps holding a key, the new keydown events are made and new calls to the callback are executed. It should fit perfectly for a game. However only one key sends a keydown event at a time. Let the first player hold down the S key. When the second player starts holding the DOWN key we won't receive the keydown event for the first player anymore.
In the following example, try to hold down 2 keys at the same time and see it for yourself: http://jsfiddle.net/mailson/TupBQ/
So how do you know the first player is pressing down the S key after the second player made its move? Well, although we may not receive the keydown anymore we should know the key is being pressed if we don't get a keyup event for that.
See another example using the latter approach: http://jsfiddle.net/mailson/jreG6/
To get our jobs easily done I've created a KeyListener class to handle this. It just listens to keydown/keyup events and keeps track of what keys are being pressed.
function KeyListener() { this.pressedKeys = []; this.keydown = function(e) { this.pressedKeys[e.keyCode] = true; }; this.keyup = function(e) { this.pressedKeys[e.keyCode] = false; }; document.addEventListener("keydown", this.keydown.bind(this)); document.addEventListener("keyup", this.keyup.bind(this)); } KeyListener.prototype.isPressed = function(key) { return this.pressedKeys[key] ? true : false; }; KeyListener.prototype.addKeyPressListener = function(keyCode, callback) { document.addEventListener("keypress", function(e) { if (e.keyCode == keyCode) callback(e); }); };
A word about the bind() function
When a event callback is called the this variable points to the element that triggered the event. Yet we need a reference to the KeyListener object in both keyup and keydown callbacks. To achieve that we use a bind() to tell the callback that this actually points to our KeyListener instance (this), not the event caller.
For further information, please read the addEventListener documentation on MDN.
Although useful the bind() function is quite recent and won't work in Internet Explorer <9 or Safari <6. Fortunately someone has already came up with a work around for that.
Using KeyListener
To move the paddles we simply create a new instance of KeyListener and check, inside the game update, if our respective keys are being pressed.
function Game() { var canvas = document.getElementById("game"); this.width = canvas.width; this.height = canvas.height; this.context = canvas.getContext("2d"); this.context.fillStyle = "white"; this.keys = new KeyListener(); this.p1 = new Paddle(5, 0); this.p1.y = this.height/2 - this.p1.height/2; this.p2 = new Paddle(this.width - 5 - 2, 0); this.p2.y = this.height/2 - this.p2.height/2; } Game.prototype.update = function() { if (this.paused) return; // To which Y direction the paddle is moving if (this.keys.isPressed(83)) { // DOWN this.p1.y = Math.min(this.height - this.p1.height, this.p1.y + 4); } else if (this.keys.isPressed(87)) { // UP this.p1.y = Math.max(0, this.p1.y - 4); } if (this.keys.isPressed(40)) { // DOWN this.p2.y = Math.min(this.height - this.p2.height, this.p2.y + 4); } else if (this.keys.isPressed(38)) { // UP this.p2.y = Math.max(0, this.p2.y - 4); } };
Our progress so far:
The ball
The ball element is not controlled by the user. Instead, it moves around the viewport with an X and Y velocity. When the ball hits a paddle, it changes to the opposite X direction but keeps the Y velocity. When the ball hits the top or bottom of the screen, it moves in the opposite Y direction while the X velocity remains the same. If it crosses the left or right side of the screen, the player of the opposite side scores.
For now we're going to implement a very basic collision detection for the ball.
function Ball() { this.x = 0; this.y = 0; this.vx = 0; this.vy = 0; this.width = 4; this.height = 4; } Ball.prototype.update = function() { this.x += this.vx; this.y += this.vy; }; Ball.prototype.draw = function(p) { p.fillRect(this.x, this.y, this.width, this.height); };
This is a simple class which stores the ball's attributes. The position is updated on each update() call.
function Game() { // [...] this.ball = new Ball(); this.ball.x = this.width/2; this.ball.y = this.height/2; this.ball.vy = Math.floor(Math.random()*12 - 6); this.ball.vx = 7 - Math.abs(this.ball.vy); } Game.prototype.update = function() { if (this.paused) return; // [...] this.ball.update(); if (this.ball.x > this.width || this.ball.x + this.ball.width < 0) { this.ball.vx = -this.ball.vx; } else if (this.ball.y > this.height || this.ball.y + this.ball.height < 0) { this.ball.vy = -this.ball.vy; } }; Game.prototype.draw = function() { this.context.clearRect(0, 0, this.width, this.height); this.context.fillRect(this.width/2, 0, 2, this.height); this.ball.draw(this.context); this.p1.draw(this.context); this.p2.draw(this.context); };
It's starting to look like a game, isn't it?
Improving the collision detection
You probably noticed that the ball is not actually colliding with the paddles. We're not even checking where the paddle is on our collision detection algorithm.
A simple solution would be checking on game's update if the ball intersects with either of the paddles, then change the X velocity accordingly.
However this is not a safe check. The ball moves more than 1 pixel per loop and situations like the illustrated below often occurs.
To avoid this tunneling effect, we need to "guess" where the ball was at the instant of collision and translate the ball to that position.
I won't spend much time talking about the collision detection. The following code explains for itself.
Game.prototype.update = function() { if (this.paused) return; this.ball.update(); // To which Y direction the paddle is moving if (this.keys.isPressed(83)) { // DOWN this.p1.y = Math.min(this.height - this.p1.height, this.p1.y + 4); } else if (this.keys.isPressed(87)) { // UP this.p1.y = Math.max(0, this.p1.y - 4); } if (this.keys.isPressed(40)) { // DOWN this.p2.y = Math.min(this.height - this.p2.height, this.p2.y + 4); } else if (this.keys.isPressed(38)) { // UP this.p2.y = Math.max(0, this.p2.y - 4); } if (this.ball.vx > 0) { if (this.p2.x <= this.ball.x + this.ball.width && this.p2.x > this.ball.x - this.ball.vx + this.ball.width) { var collisionDiff = this.ball.x + this.ball.width - this.p2.x; var k = collisionDiff/this.ball.vx; var y = this.ball.vy*k + (this.ball.y - this.ball.vy); if (y >= this.p2.y && y + this.ball.height <= this.p2.y + this.p2.height) { // collides with right paddle this.ball.x = this.p2.x - this.ball.width; this.ball.y = Math.floor(this.ball.y - this.ball.vy + this.ball.vy*k); this.ball.vx = -this.ball.vx; } } } else { if (this.p1.x + this.p1.width >= this.ball.x) { var collisionDiff = this.p1.x + this.p1.width - this.ball.x; var k = collisionDiff/-this.ball.vx; var y = this.ball.vy*k + (this.ball.y - this.ball.vy); if (y >= this.p1.y && y + this.ball.height <= this.p1.y + this.p1.height) { // collides with the left paddle this.ball.x = this.p1.x + this.p1.width; this.ball.y = Math.floor(this.ball.y - this.ball.vy + this.ball.vy*k); this.ball.vx = -this.ball.vx; } } } // Top and bottom collision if ((this.ball.vy < 0 && this.ball.y < 0) || (this.ball.vy > 0 && this.ball.y + this.ball.height > this.height)) { this.ball.vy = -this.ball.vy; } };
Now we have a game:
Counting the score
The goal in this game is very simple: a player needs to make the ball cross the wall on the opposite side.
Game.prototype.update = function() { // [...] if (this.ball.x >= this.width) this.score(this.p1); else if (this.ball.x + this.ball.width <= 0) this.score(this.p2); }; Game.prototype.score = function(p) { // player scores p.score++; var player = p == this.p1 ? 0 : 1; // set ball position this.ball.x = this.width/2; this.ball.y = p.y + p.height/2; // set ball velocity this.ball.vy = Math.floor(Math.random()*12 - 6); this.ball.vx = 7 - Math.abs(this.ball.vy); if (player == 1) this.ball.vx *= -1; };
We also need a display to show the score
function Display(x, y) { this.x = x; this.y = y; this.value = 0; } Display.prototype.draw = function(p) { p.fillText(this.value, this.x, this.y); };
function Game() { // [...] this.p1 = new Paddle(5, 0); this.p1.y = this.height/2 - this.p1.height/2; this.display1 = new Display(this.width/4, 25); this.p2 = new Paddle(this.width - 5 - 2, 0); this.p2.y = this.height/2 - this.p2.height/2; this.display2 = new Display(this.width*3/4, 25); } Game.prototype.draw = function() { // [...] this.display1.draw(this.context); this.display2.draw(this.context); }; Game.prototype.update = function() { if (this.paused) return; this.ball.update(); this.display1.value = this.p1.score; this.display2.value = this.p2.score; // [...] };
Using touch events
In order to make it work on mobile devices we need to listen to touch events such as touchstart, touchend or touchmove. Each of those events has a changedTouches attribute of type TouchList which is a list of Touch objects.
Please note that mouse clicks won't dispatch a touch event. Fortunately you can easily simulate touch events in Chrome.
I noticed that in my browser I always got a new Touch object on each event call. However when I tested the game on my iPad, initially I couldn't make it work. I was storing the previous Touch object to compare to the new one and calculate a movement. After some troubleshooting I found out that the browser wasn't giving me a new Touch instance, instead it was just editing the attributes of the Touch reference I already got. So I couldn't just store the Touch object to compare with the next event, I had to actually store the values I wanted from it (clientX and clientY).
Given that I came out with a TouchListener
function TouchListener(element) { this.touches = []; this.touchMoveListener = function(touch) {}; element.addEventListener("touchstart", (function(e) { e.preventDefault(); for (var i = 0; i < e.changedTouches.length; i++) { var touch = e.changedTouches[i]; this.touches[touch.identifier] = {x: touch.clientX, y: touch.clientY}; } }).bind(this)); element.addEventListener("touchmove", (function(e) { e.preventDefault(); for (var i = 0; i < e.changedTouches.length; i++) { var touch = e.changedTouches[i]; var previousTouch = this.touches[touch.identifier]; this.touches[touch.identifier] = {x: touch.clientX, y: touch.clientY}; var offset = {x: touch.clientX - previousTouch.x, y: touch.clientY - previousTouch.y} this.touchMoveListener({x: touch.clientX, y: touch.clientY, offset: offset}); } }).bind(this)); element.addEventListener("touchend", (function(e) { e.preventDefault(); for (var i = 0; i < e.changedTouches.length; i++) { delete this.touches[e.changedTouches[i].identifier]; } }).bind(this)); }
This helper class stores each touch event in a hash table using the Touch identifier as a key. The offset of the touch movement is calculated comparing the current touch position with the previously stored one. The result is then passed as an argument of a callback.
Conclusion
Making HTML5 games requires the use of some functionalities that are not fully implemented even on modern browsers. Also, since browsers development are happening really fast, a few months can make a huge difference in browsers versions. So if you plan to support a variety of platforms, you'll have to take some time to check if that awesome functionality X were supported a few months ago (Safari 5 and Safari 6 are just one year apart and the difference between them is huge).
Making this game had taught me that:
- There's no bind() on iOS 5
- Safari doesn't create a new Touch object for each touch event
- You need to use mouse events, not touch events on windows phone
- Browser fragmentation it's a huge problem for web apps
- That functionality you never heard about probably won't be supported on outdated browsers
If you have any question, please use the comments section below.
Comments