JavaScript: Using ChipmunkJS to build ping pong
A couple of weeks ago, we picked up a RaspberryPi for the office and started brainstorming ideas for cool hacks for it. One of the ideas that was floated was using the Pi as a server for some sort of multiplayer JavaScript game. I’ve actually never written a game before so I figured it would be an interesting project. We ended up trying to use node-qt for graphics along with ws to receive input via websockets but the refresh rate of Qt was just to low. A story for another time.
Anyway, so back to ping pong. One of the things we wanted to avoid was writing our own code to manage the game objects, we really wanted to use a game engine for this. The only caveat we had was that the engine had to be UI agnostic since we were planning to output graphics on Qt instead of a HTML5 Canvas. After looking around, most of the JavaScript game engines have strong dependencies on Canvas so we started looking at other options. The two strongest options were Box2JS and ChipmunkJS. We decided to go with ChipmunkJS in part because it was handwritten while Box2JS was automatically converted from ActionScript so the resulting code is harder to follow.
Getting ChipmunkJS setup is relatively painless, just include the file and you’re off to the races. Since we were looking to eventually use Qt, we tried our best to cleanly separate the Chipmunk code from what we’d be using to draw the objects on the screen. Because of that, it’s pretty easy to follow what’s going on with the Chipmunk code. If you take a look at cpPong.js it basically initializes the physics space and then starts the game.
// The physics space size is 640x480, with the origin in the bottom left.
// Its really an arbitrary number except for the ratio - everything is done
// in floating point maths anyway.
var GRABABLE_MASK_BIT = 1<<31;
var NOT_GRABABLE_MASK = ~GRABABLE_MASK_BIT;
if( typeof cp == 'undefined') {
var cp = require("./cp.js");
}
var cpPong = function(){
this.addDrawables();
var ball = null, paddleOne = null, paddleTwo = null;
this.v = cp.v;
this.space = new cp.Space();
this.space.iterations = 10;
this.space.gravity = this.v(0, 0);
this.space.sleepTimeThreshold = 0.5;
this.space.collisionSlop = 0.1;
this.addFloor();
this.addWalls();
var mass = 10;
var radius = 20;
this.body = this.space.addBody( new cp.Body(mass, cp.momentForCircle(mass, 0, radius, this.v(0, 0))) );
this.ball = this.space.addShape( new cp.CircleShape(this.body, radius, this.v(0, 0)) );
this.ball.setElasticity(1);
this.ball.setFriction(0.0);
this.ball.setCollisionType(1);
this.paddleOneBody = this.space.addBody( new cp.Body(100000, cp.momentForBox(Infinity, 75, 10)) );
this.paddleOne = this.space.addShape( new cp.BoxShape(this.paddleOneBody, 75, 10) );
this.paddleOne.setElasticity(1);
this.paddleOne.setFriction(0);
this.paddleTwoBody = this.space.addBody( new cp.Body(100000, cp.momentForBox(Infinity, 75, 10)) );
this.paddleTwo = this.space.addShape( new cp.BoxShape(this.paddleTwoBody, 75, 10) );
this.paddleTwo.setElasticity(1);
this.paddleTwo.setFriction(0);
this.scoreboard = {s1: 0, s2: 0};
var self = this;
this.resetGameObjects();
this.renderScoreBoard();
this.isRunning = true;
this.run();
this.space.setDefaultCollisionHandler(null, null, function(el){
if( el.a.isGameEnder || el.b.isGameEnder ){
var pointFor = el.a.pointFor ? el.a.pointFor : el.b.pointFor;
self.isRunning = false;
self.scoreboard[ pointFor ] += 1;
self.renderScoreBoard();
self.alertRoundOver();
self.resetGame();
}
});
};
cpPong.prototype.resetGameObjects = function(){
this.body.setPos( this.v(20, 320) );
this.body.setVel( this.v(200, -200) );
this.paddleOneBody.setPos( this.v(320, 60) );
this.paddleTwoBody.setPos( this.v(320, 420) );
this.paddleOneBody.setVel( this.v(0, 0) );
this.paddleTwoBody.setVel( this.v(0, 0) );
};
cpPong.prototype.addFloor = function() {
this.floor = this.space.addShape( new cp.SegmentShape(this.space.staticBody, this.v(0, 0), this.v(640, 0), 0) );
this.floor.setElasticity(1);
this.floor.setFriction(0.0);
this.floor.setCollisionType(1);
this.floor.isGameEnder = true;
this.floor.pointFor = "s1";
this.floor.lineWidth = 5;
this.ceiling = this.space.addShape( new cp.SegmentShape(this.space.staticBody, this.v(0, 480), this.v(640, 480), 0) );
this.ceiling.setElasticity(1);
this.ceiling.setFriction(0.0);
this.ceiling.setCollisionType(1);
this.ceiling.isGameEnder = true;
this.ceiling.pointFor = "s2";
this.ceiling.lineWidth = 5;
};
cpPong.prototype.addWalls = function() {
var space = this.space;
var wall1 = space.addShape(new cp.SegmentShape(space.staticBody, this.v(0, 0), this.v(0, 480), 0));
wall1.setElasticity(1);
wall1.setFriction(0.0);
wall1.setLayers(NOT_GRABABLE_MASK);
wall1.setCollisionType(1);
wall1.lineWidth = 5;
var wall2 = space.addShape(new cp.SegmentShape(space.staticBody, this.v(640, 0), this.v(640, 480), 0));
wall2.setElasticity(1);
wall2.setFriction(0.0);
wall2.setLayers(NOT_GRABABLE_MASK);
wall2.setCollisionType(1);
wall2.lineWidth = 5;
};
cpPong.prototype.movePaddle = function(paddle, direction){
var target = paddle == "one" ? this.paddleOneBody : this.paddleTwoBody;
var velocity = direction == "right" ? 500 : -500;
var pt = target.getVel().add( this.v(velocity, 0) );
target.setVel( this.v(velocity, 0) );
// target.setVel( pt );
};
//Export the Underscore object for **Node.js**, with
// backwards-compatibility for the old `require()` API. If we're in
// the browser, add `_` as a global object via a string identifier,
// for Closure Compiler "advanced" mode.
if (typeof exports !== 'undefined') {
if (typeof module !== 'undefined' && module.exports) {
exports = module.exports = cpPong;
}
exports.cpPong = cpPong;
} else {
window.cpPong = cpPong;
}
With the physics space setup, the next step is setting up the drawing. Following the Chipmunk demo’s lead, I added specific “draw” methods to the Prototypes of each shape. Doing this, allows you to just iterate over each shape in the scene and call “draw” to have it appear on Canvas or Qt. Check out pong.html to see how we added the draw methods to the shapes. And that’s about it! The only other interesting part is using “requestAnimationFrame” to avoid using setTimeout to update the scene.
var scoreboardTemplate = _.template( $("#scoreboardTemplate").html() );
var ctx;
var canvas = $("canvas")[0];
var canvasBackend = {
scale: 0,
run: function(){
if( this.isRunning == false ){
return false;
}
this.drawScene();
this.space.step(1/60);
var raf = window.requestAnimationFrame
|| window.webkitRequestAnimationFrame
|| window.mozRequestAnimationFrame
|| window.oRequestAnimationFrame
|| window.msRequestAnimationFrame
|| function(callback) {
return window.setTimeout(callback, 1000 / 60);
};
var draw = _.bind(this.run, this);
raf(draw);
},
drawScene: function(){
var self = this;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.space.eachShape(function(shape) {
shape.draw(self.ctx);
});
},
resetGame: function(){
var self = this;
window.setTimeout(function(){
self.resetGameObjects();
self.isRunning = true;
self.run();
}, 3000);
},
renderScoreBoard: function(){
$("#scoreboard").html( scoreboardTemplate(this.scoreboard) );
},
alertRoundOver: function(){
alert("Round over!");
},
addDrawables: function(){
this.canvas = $("canvas")[0];
this.ctx = $("canvas")[0].getContext('2d');
var width = Math.ceil( (window.innerHeight * 640) / 480 ) + 25;
$("canvas").attr("height", window.innerHeight);
$("canvas").attr("width", width);
// var width = this.canvas.width;
var height = this.canvas.height;
window.scale = 0;
if (width / height > 640 / 480) {
window.scale = height / 480;
} else {
window.scale = width / 640;
}
var self = this;
$(window).keydown(function(e){
if( e.keyCode == 39 ){
self.movePaddle("one", "right");
}
if( e.keyCode == 37 ){
self.movePaddle("one", "left");
}
if( e.keyCode == 87 ){
self.movePaddle("two", "right");
}
if( e.keyCode == 81 ){
self.movePaddle("two", "left");
}
});
cp.Shape.prototype.point2canvas = function(point){
return cp.v( point.x * window.scale, (480 - point.y) * window.scale );
};
cp.Shape.prototype.drawLine = function(ctx, a, b){
a = this.point2canvas(a);
b = this.point2canvas(b);
ctx.beginPath();
ctx.moveTo(a.x + .5, a.y);
ctx.lineTo(b.x + .5, b.y);
ctx.stroke();
};
cp.Shape.prototype.drawCircle = function(ctx, c, radius){
var c = this.point2canvas(c);
ctx.beginPath();
ctx.arc(c.x, c.y, window.scale * radius, 0, 2 * Math.PI, false);
ctx.fill();
ctx.stroke();
}
cp.SegmentShape.prototype.draw = function(ctx) {
ctx.strokeStyle = "green";
ctx.fillStyle = "green";
ctx.lineCap = 'round';
ctx.lineWidth = this.lineWidth ? this.lineWidth : Math.max(1, this.r * window.scale * 2);
this.drawLine(ctx, this.ta, this.tb);
};
cp.CircleShape.prototype.draw = function(ctx) {
ctx.lineWidth = 1;
ctx.strokeStyle = "black";
ctx.fillStyle = "red";
this.drawCircle(ctx, this.tc, this.r);
ctx.strokeStyle = "white";
ctx.fillStyle = "white";
this.drawLine(ctx, this.tc, cp.v.mult(this.body.rot, this.r).add(this.tc));
};
cp.PolyShape.prototype.draw = function(ctx){
ctx.strokeStyle = "blue";
ctx.fillStyle = "blue";
ctx.lineWidth = 1;
ctx.beginPath();
var verts = this.tVerts;
var len = verts.length;
var lastPoint = this.point2canvas( new cp.Vect(verts[len - 2], verts[len - 1]) );
ctx.moveTo(lastPoint.x, lastPoint.y);
for(var i = 0; i < len; i+=2){
var p = this.point2canvas(new cp.Vect(verts[i], verts[i+1]));
ctx.lineTo(p.x, p.y);
}
ctx.fill();
ctx.stroke();
};
},
};
_.extend( cpPong.prototype, canvasBackend );
There’s a live demo running here and of course the code on GitHub.
Any questions or comments welcome!