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:

<!DOCTYPE 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 &lt; margin.top || y+R &gt; Screen().height-margin.bottom) {
                    vector.y = -vector.y;
                }
 
                // bounce off right paddle or score
                if (x+R &gt; 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 &lt; 
                    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 &gt; 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