Swizec Teller - a geek with a hatswizec.com

Senior Mindset Book

Get promoted, earn a bigger salary, work for top companies

Senior Engineer Mindset cover
Learn more

    (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
    Published on May 28th, 2014 in Cascading Style Sheets, CSS, d3.js, Graphics, HTML, Scalable Vector Graphics, Uncategorized

    Did you enjoy this article?

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

    Semantically similar articles hand-picked by GPT-4

    Senior Mindset Book

    Get promoted, earn a bigger salary, work for top companies

    Learn more

    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 ❤️

    Created by Swizec with ❤️