Skip to content
Swizec Teller - a geek with a hatswizec.com

(ab)Using d3.js to make a Pong game

D3.js is a data visualization library first and foremost. That's what people use it for. To make shiny things that blow everyone's minds.

But D3 is more than that. It's a powerful SVG manipulation library. Yes, some people would say "But you don't need an SVG manipulation library! You can just write SVG like you do HTML". Those people are silly and probably write their own time manipulation functions as well.

Recently I made a simple game of Pong using D3. Nothing fancy, just two paddles that you can drag around, a ball that bounces to and fro, two score counters, and reacting to orientation changes on mobile devices. There isn't even a start or stop button.

You can play the game here, and see the code here.

It was a quick project though, so it doesn't work on Firefox because of a weird SVG canvas sizing bug (I needed it to spread the whole screen) and some people have told me dragging the paddles doesn't work on desktop. Worked for me. shrug

Putting it together

There really isn't much to making a game like this with D3.

First we need some minimal HTML:

<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"
/>
<title>D3 pong</title>
<link
href="http://fonts.googleapis.com/css?family=Overlock"
rel="stylesheet"
type="text/css"
/>
<link rel="stylesheet" href="style.css" />
<main></main>
<script src="http://d3js.org/d3.v3.min.js" charset="utf-8"></script>
<script src="d3-pong.js"></script>

The HTML isn't interesting. Our game will go in the <main></main> tag. The rest is about loading necessary files and telling mobile browsers not to act funny.

We also need some CSS to make sure our game fills the entire screen and things look decent.

html,
body,
main {
height: 100%;
padding: 0;
margin: 0;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
svg {
width: 100%;
height: 99%; /* gets rid of scrollbar */
}
text {
font-family: "Overlock", cursive;
font-size: 1.5em;
}
line {
stroke: black;
stroke-width: 2;
}
.area {
fill: white;
stroke: "red";
}

The base elements

After that ground work, the fun begins.

We create the SVG element, define some useful margins, and a helper function that turns css properties like "10px" into numbers.

var svg = d3.select("main").append("svg"),
margin = { top: 10, right: 10, bottom: 10, left: 10 },
parse = function (N) {
return Number(N.replace("px", ""));
};

Simple.

The Screen function will always tell us how much room we've got.

var Screen = function () {
return {
width: parse(svg.style("width")),
height: parse(svg.style("height")),
};
};

Next up, making a paddle. For extra fun, I made it so creating a paddle returns a function we can call whenever we want to update the paddle's position.

Paddle = function (which) {
var width = 5,
area = svg.append('rect')
.classed('area', true)
.attr({width: width*7}),
paddle = svg.append('rect')
.classed('paddle', true)
.classed(which+"_paddle", true)
.attr({width: 5}),
update = function (x, y) {
var height = Screen().height*0.15;
paddle.attr({
x: x,
y: y,
height: height
});
area.attr({
x: x-width*5/2,
y: y,
height: height
});
return update;
};

Because fingers are fat and paddles are thin, we defined an area that's bigger than the actual paddle. This will be used as the drag handle. Then we've got the paddle itself - both of these are just SVG rectangles.

The update function si a little bit more interesting, but not much. It just takes an x, y position and updates paddle and area.

To make paddles draggable either with a mouse or a finger, we're going to use D3's draggable behavior. A pre-built function we can call on any SVG element to create listeners for all relevant events.

All it requires of us is defining what actually happens when something gets dragged.

This goes inside the Paddle function.

// make paddle draggable
var drag = d3.behavior.drag()
.on("drag", function () {
var y = parse(area.attr("y")),
height = Screen().height*0.1;
update(parse(paddle.attr("x")),
Math.max(margin.top,
Math.min(parse(paddle.attr("y"))+d3.event.dy,
Screen().height-margin.bottom-height)));
})
.origin(function () {
return {x: parse(area.attr("x")),
y: parse(area.attr("y"))};
});
area.call(drag);
return update;
},

The "drag" event represents any type of either mouse or touch event that might represent dragging. In the callback we essentially just call the update function with new coordinates. All that Math.max and Math.min nonsense makes sure paddles can't be dragged out of the screen.

.origin is something this behaviour needs to calculate positions properly. It's best to just set it to whatever is the current position of our element.

area.call(drag); activates the draggable behaviour on our draggable area.

Next up - a function that keeps score.

// generates a score, returns function for updating value and repositioning score
Score = function (x) {
var value = 0,
score = svg.append('text')
.text(value);
return function f(inc) {
value += inc;
score.text(value)
.attr({x: Screen().width*x,
y: margin.top*3});
return f;
};
},

We're going for the trick with returning update functions again. But other than that it's really simple, just add a text element to the drawing area and give it a value. Don't worry about that repositioning stuff for now.

Nearly the same goes for the middle line - add a line, make sure it can be moved when needed.

// generates middle line, returns function for updating position
Middle = function () {
var line = svg.append('line');
return function f() {
var screen = Screen();
line.attr({
x1: screen.width/2,
y1: margin.top,
x2: screen.width/2,
y2: screen.height-margin.bottom
});
return f;
};

The ball is going to be a bit more fun. Not only does it have to draw a simple circle and be able to move it around, it should also react to hitting obstacles and updating scores.

This time we're going to return a function that does a full step of the main animation. Things might get hairy.

Ball = function () {
var R = 5,
ball = svg.append('circle')
.classed("ball", true)
.attr({r: R,
cx: Screen().width/2,
cy: Screen().height/2}),
scale = d3.scale.linear().domain([0, 1]).range([-1, 1]),
vector = {x: scale(Math.random()),
y: scale(Math.random())},
speed = 7;

We started with the simple stuff - drawing a ball, defining a random vector, and making up a speed that looked good on my screen.

The collision logic is hairier.

var hit_paddle = function (y, paddle) {
return (
y - R > parse(paddle.attr("y")) &&
y + R < parse(paddle.attr("y")) + parse(paddle.attr("height"))
);
},
collisions = function () {
var x = parse(ball.attr("cx")),
y = parse(ball.attr("cy")),
left_p = d3.select(".left_paddle"),
right_p = d3.select(".right_paddle");
// collision with top or bottom
if (y - R < margin.top || y + R > Screen().height - margin.bottom) {
vector.y = -vector.y;
}
// bounce off right paddle or score
if (x + R > parse(right_p.attr("x"))) {
if (hit_paddle(y, right_p)) {
vector.x = -vector.x;
} else {
return "left";
}
}
// bounce off left paddle or score
if (x - R < parse(left_p.attr("x")) + parse(left_p.attr("width"))) {
if (hit_paddle(y, left_p)) {
vector.x = -vector.x;
} else {
return "right";
}
}
return false;
};

Hokay.

hit_paddle is a helper function that tells us whether the ball is touching a paddle - paddle position minus ball radius. Simple.

collisions looks hairy, but it's very repetitive:

  • if the ball hits top or bottom edge, its vertical position should flip.
  • if the ball is to the right enough to hit the paddle, it will either flip its horizontal direction, or tell the calling code that "right" messed up
  • same thing on the left
  • if nothing happens, return false

The last part of the Paddle function is the function that performs an animation step.

return function f(left, right, delta_t) {
var screen = Screen(),
// this should pretend we have 100 fps
fps = delta_t > 0 ? (delta_t/1000)/100 : 1;
ball.attr({
cx: parse(ball.attr("cx"))+vector.x*speed*fps,
cy: parse(ball.attr("cy"))+vector.y*speed*fps
});
var scored = collisions();
if (scored) {
if (scored == "left") {
left.score(1);
}else{
right.score(1);
}
return true;
}
return false;
};
};

It's pretty simple. First we update the ball's position according to the current vector, then we check for collisions and update scores if need be. Lastly we return true or false depending on whether we want the current animation to continue or not.

The main bit

Now that we've got all the elements, which sneakily contain most of our code already, it's time to put it all in motion.

// generate starting scene
var left = {
score: Score(0.25)(0),
paddle: Paddle("left")(margin.left, Screen().height / 2),
},
right = {
score: Score(0.75)(0),
paddle: Paddle("right")(Screen().width - margin.right, Screen().height / 2),
},
middle = Middle()(),
ball = Ball();

left and right hold each player's score and paddle update functions, and middle and ball are the middle line and the ball.

We also have to react to window resizing. This sneakily captures orientation changes as well.

// detect window resize events (also captures orientation changes)
d3.select(window).on("resize", function () {
var screen = Screen();
left.score(0);
left.paddle(margin.left, screen.height / 2);
right.score(0);
right.paddle(screen.width - margin.right, screen.height / 2);
middle();
});

When the screen changes, we just update the position of everything. See how always returning an update function made our life easier?

And finally, we start the animation.

// start animation timer that runs until a player scores
// then reset ball and start again
function run() {
var last_time = Date.now();
d3.timer(function () {
var now = Date.now(),
scored = ball(left, right, now - last_time),
last_time = now;
if (scored) {
d3.select(".ball").remove();
ball = Ball();
run();
}
return scored;
}, 500);
}
run();

We used a d3.timer to create a custom animation loop that's tied to the graphics speed of the user's device. To counter for this, we feed a time delta into our ball animation function to create the appearance of consistent speed.

Actual game developers told me I have to do that, so I did.

When a user scores, we reset the ball to its current position and re-run everything. d3.timer's main loop runs for as long as the function keeps returning false. We took care of that by returning scored.

Fin

And that's it. (ab)Using D3 to make a simple Pong game because we can. It was a fun hack, there are a million better tools you could use to make this, but I had fun.

Enhanced by Zemanta

Did you enjoy this article?

Published on May 28th, 2014 in Cascading Style Sheets, CSS, d3.js, Graphics, HTML, Scalable Vector Graphics, Uncategorized

Learned something new?
Want to become a high value JavaScript expert?

Here's how it works 👇

Leave your email and I'll send you an Interactive Modern JavaScript Cheatsheet 📖right away. After that you'll get thoughtfully written emails every week about React, JavaScript, and your career. Lessons learned over my 20 years in the industry working with companies ranging from tiny startups to Fortune5 behemoths.

Start with an interactive cheatsheet 📖

Then get thoughtful letters 💌 on mindsets, tactics, and technical skills for your career.

"Man, love your simple writing! Yours is the only email I open from marketers and only blog that I give a fuck to read & scroll till the end. And wow always take away lessons with me. Inspiring! And very relatable. 👌"

~ Ashish Kumar

Join over 10,000 engineers just like you already improving their JS careers with my letters, workshops, courses, and talks. ✌️

Have a burning question that you think I can answer? I don't have all of the answers, but I have some! Hit me up on twitter or book a 30min ama for in-depth help.

Ready to Stop copy pasting D3 examples and create data visualizations of your own?  Learn how to build scalable dataviz components your whole team can understand with React for Data Visualization

Curious about Serverless and the modern backend? Check out Serverless Handbook, modern backend for the frontend engineer.

Ready to learn how it all fits together and build a modern webapp from scratch? Learn how to launch a webapp and make your first 💰 on the side with ServerlessReact.Dev

Want to brush up on your modern JavaScript syntax? Check out my interactive cheatsheet: es6cheatsheet.com

By the way, just in case no one has told you it yet today: I love and appreciate you for who you are ❤️