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.
Continue reading about (ab)Using d3.js to make a Pong game
Semantically similar articles hand-picked by GPT-4
- JavaScript’s most popular dataviz library
- Quick scatterplot tutorial for d3.js
- Flotr2 - my favorite javascript graph library
- 3 key insights that make D3.js easy to learn
- Livecoding #34: A Map of Global Migrations, Part 3
Learned something new?
Read more Software Engineering Lessons from Production
I write articles with real insight into the career and skills of a modern software engineer. "Raw and honest from the heart!" as one reader described them. Fueled by lessons learned over 20 years of building production code for side-projects, small businesses, and hyper growth startups. Both successful and not.
Subscribe below 👇
Software Engineering Lessons from Production
Join Swizec's Newsletter and get insightful emails 💌 on mindsets, tactics, and technical skills for your career. Real lessons from building production software. No bullshit.
"Man, love your simple writing! Yours is the only newsletter I open 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. 👌"
Have a burning question that you think I can answer? Hit me up on twitter and I'll do my best.
Who am I and who do I help? I'm Swizec Teller and I turn coders into engineers with "Raw and honest from the heart!" writing. No bullshit. Real insights into the career and skills of a modern software engineer.
Want to become a true senior engineer? Take ownership, have autonomy, and be a force multiplier on your team. The Senior Engineer Mindset ebook can help 👉 swizec.com/senior-mindset. These are the shifts in mindset that unlocked my career.
Curious about Serverless and the modern backend? Check out Serverless Handbook, for frontend engineers 👉 ServerlessHandbook.dev
Want to Stop copy pasting D3 examples and create data visualizations of your own? Learn how to build scalable dataviz React components your whole team can understand with React for Data Visualization
Want to get my best emails on JavaScript, React, Serverless, Fullstack Web, or Indie Hacking? Check out swizec.com/collections
Did someone amazing share this letter with you? Wonderful! You can sign up for my weekly letters for software engineers on their path to greatness, here: swizec.com/blog
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 ❤️