/ Dart

Remaking Pong in Dart


Clone a classic game, and play it from the comfort of your Web browser.

Pong is a classic game, created decades ago. Though it was designed to emulate table tennis, it actually plays more like a computerized air hockey. It was the first commercially successful video game, and thus is impossible to get your hands on without an investment of a lot of money; however, we can create it ourselves in a matter of hours, and play it for free! It sure sounds an awful lot like piracy, but it is fun.

Play the built version here:
https://thosakwe.github.io/dart-pong/

Getting Started

Scaffold a new Dart Web project, using Stagehand.

mkdir pong && cd pong
stagehand web-simple

Add the following dependency to your pubspec.yaml:

dependencies:
  color: ^2.0.2

Next, head to web/index.html. Remove the #output element, and add the following <canvas>. It will serve as the container for our game.

<canvas id="game" width="800" height="600"></canvas>

Let's create the base of our game - a black screen. In the background, we want to run a 60 fps game loop, and update the game state and redraw every cycle.

Create two Dart files: lib/pong.dart and lib/src/game.dart:

// Place the following in `lib/pong.dart`.
// Our root library simply exports our [Game] class.
export 'src/game.dart';

// Place the following skeleton in `lib/src/game.dart`:
import 'dart:async';
import 'dart:html' as $;
import 'package:color/color.dart';

class Game {
  static final RgbColor backgroundColor = RgbColor.namedColors['black'];
  final $.CanvasElement canvas;
  final $.CanvasRenderingContext2D context;
  Timer _gameTime;

  Game(this.canvas)
      : context = canvas.getContext('2d');

  void start() {
    _gameTime = new Timer.periodic(new Duration(milliseconds: (1000 / 60).round()), (timer) {
      update(timer);
      draw(timer);
    });
  }

  void stop() {
    _gameTime.cancel();
  }

  void update(Timer timer) {}

  void draw(Timer timer) {
    // Clear to background screen
    context.setFillColorRgb(
        backgroundColor.r, backgroundColor.g, backgroundColor.b);
    context.fillRect(0, 0, canvas.width, canvas.height);
  }
}

Running the game should display a black screen.

Empty black screen

The Paddles

In Pong, paddles are the means through which a player interacts with the game. The game's graphics are extremely simplistic, so these can be naught more than mere rectangles attached to the top and bottom of the screen.

In lib/src/paddle.dart:

import 'dart:async';
import 'dart:html' as $;
import 'dart:math';
import 'package:color/color.dart';

class Paddle {
  static const Point<int> size = const Point(100, 15);
  static final RgbColor color = RgbColor.namedColors['magenta'];
  Point<int> position;

  Paddle(this.position);

  void draw(Timer timer, $.CanvasRenderingContext2D context) {
    context.setFillColorRgb(color.r, color.g, color.b);
    context.fillRect(position.x, position.y, size.x, size.y);
  }
}

Now that we've written logic to draw the paddles, let's head back to lib/src/game.dart and hook them up to the actual game:

import 'dart:math';

class Game {
  Paddle paddle1, paddle2;
  
  void start() {
    int centerX = (canvas.width ~/ 2) - (Paddle.size.x ~/ 2);
    paddle1 = new Paddle(new Point<int>(centerX, canvas.height - Paddle.size.y));
    paddle2 = new Paddle(new Point<int>(centerX, 0));
    // Timer logic omitted...
  }
  
  void draw(Timer timer) {
    // Logic to clear the background omitted...
    
    // Draw both paddles, wherever they may be.
    paddle1.draw(timer, context);
    paddle2.draw(timer, context);
  }
}

Run the game, and you'll see the paddles drawn to the screen.

Magenta rectangles drawn at the top and bottom of the screen

Input

Rectangles are nice and all, but what's a paddle if it can't move? Let's hook up one of our paddles to accept keyboard input. We can easily implement a simple keyboard driver in lib/src/keyboard.dart:

import 'dart:async';
import 'dart:html' as $;

class Keyboard {
  final $.Element container;
  final Map<int, bool> _keys = {};
  StreamSubscription _keyUp, _keyDown;

  Keyboard(this.container);

  bool isDown(int keyCode) => _keys.putIfAbsent(keyCode, () => false);

  void listen() {
    _keyUp = container.onKeyUp.listen((e) {
      _keys[e.keyCode] = false;
    });

    _keyDown = container.onKeyDown.listen((e) {
      _keys[e.keyCode] = true;
    });
  }

  void close() {
    _keyUp.cancel();
    _keyDown.cancel();
  }
}

However, not all paddles are created equal. One is controlled by user input, but the other will eventually be controlled by an AI. We can abstract this away into a simple interface called InputController.

In lib/src/input_controller.dart:

import 'dart:async';
import 'dart:math';
import 'keyboard.dart';

abstract class InputController {
  Point<int> update(Point<int> currentPosition, Keyboard keyboard, Timer timer);
}

class AIInputController implements InputController {
  const AIInputController();

  @override
  Point<int> update(Point<int> currentPosition, Keyboard keyboard, Timer timer) {
    return currentPosition;
  }
}


class UserInputController implements InputController {
  final int speed;
  const UserInputController(this.speed);

  @override
  Point<int> update(Point<int> currentPosition, Keyboard keyboard, Timer timer) {
    return currentPosition;
  }
}

In lib/src/paddle.dart, update the Paddle class to accept and use an InputController to change its position:

import 'input_controller.dart';
import 'keyboard.dart';

class Paddle {
  final InputController inputController;

  Paddle(this.position, this.inputController);

  void update(Keyboard keyboard, Timer timer) {
    position = inputController.update(position, keyboard, timer);
  }
}

Now, head on over to lib/src/game.dart and hook the paddles' update code up to the game loop:

import 'keyboard.dart';
import 'input_controller.dart';

class Game {
  final Keyboard keyboard = new Keyboard($.document.body);

  Game(this.canvas)
      : context = canvas.getContext('2d');

  void start() {
    int centerX = (canvas.width ~/ 2) - (Paddle.size.x ~/ 2);

    paddle1 = new Paddle(
      new Point<int>(centerX, canvas.height - Paddle.size.y),
      const UserInputController(5),
    );
    paddle2 = new Paddle(
      new Point<int>(centerX, 0),
      const AIInputController(),
    );

    keyboard.listen();
    // Timer logic omitted...
  }

  void stop() {
    _gameTime.cancel();
    keyboard.close();
  }

  void update(Timer timer) {
    paddle1.update(keyboard, timer);
    paddle2.update(keyboard, timer);
  }
}

In lib/src/input_controller.dart, let's flesh out the UserInputController class, writing it to take input from the left and right arrows on the keyboard:

class UserInputController implements InputController {
  @override
  Point<int> update(
      Point<int> currentPosition, Keyboard keyboard, Timer timer) {
    if (keyboard.isDown($.KeyCode.LEFT)) {
      return new Point<int>(currentPosition.x - speed, currentPosition.y);
    } else if (keyboard.isDown($.KeyCode.RIGHT)) {
      return new Point<int>(currentPosition.x + speed, currentPosition.y);
    }

    return currentPosition;
  }
}

Now, we can move the bottom paddle.

One paddle has moved

Keeping the Paddles within Bounds

We can move our paddle, but there's nothing preventing us from moving offscreen. Let's ensure that doesn't happen.

Our Paddle class should be able to figure out where it is on the game screen. Create a getter named bounds that returns a Rectangle:

class Paddle {
  Rectangle<int> get bounds {
    return new Rectangle<int>(position.x, position.y, size.x, size.y);
  }
}

In lib/src/game.dart, we'll also need a worldBounds rectangle to compare each paddle to.

Also, write an enforceBounds method that takes a single Paddle as a parameter and prevents it from moving offscreen.

Note that enforceBounds is called after the calls to Paddle.update.

class Game {
  final Rectangle<int> worldBounds;

  Game(this.canvas)
      : context = canvas.getContext('2d'),
        worldBounds = new Rectangle(0, 0, canvas.width, canvas.height);
        
  void update(Timer timer) {
    paddle1.update(keyboard, timer);
    paddle2.update(keyboard, timer);
    enforceBounds(paddle1);
    enforceBounds(paddle2);
  }

  void enforceBounds(Paddle paddle) {
    if (paddle.position.x < worldBounds.left) {
      paddle.position = new Point<int>(0, paddle.position.y);
    } else if (paddle.bounds.right > worldBounds.right) {
      paddle.position = new Point<int>(worldBounds.right - Paddle.size.x, paddle.position.y);
    }
  }
}

Run the game; you'll be pleased to see that you can no longer move past the world boundaries.

Movement restricted to the bounds of canvas

The Ball

What's a game of Pong without a ball?

In lib/src/ball.dart, create another rectangle. This time, though, that rectangle should also be a square! It should also move up or down every frame, depending on its orientation:

import 'dart:async';
import 'dart:html' as $;
import 'dart:math';
import 'paddle.dart';

class Ball {
  static final Point<int> size = new Point(Paddle.size.y, Paddle.size.y);
  final int speed;
  int orientation = 1;
  Point<int> position;

  Ball(this.speed, this.position);

  Rectangle<int> get bounds {
    return new Rectangle<int>(position.x, position.y, size.x, size.y);
  }

  void update(Timer timer) {
    position = new Point<int>(position.x, position.y + (orientation * speed));
  }

  void draw($.CanvasRenderingContext2D context) {
    var color = Paddle.color;
    context.setFillColorRgb(color.r, color.g, color.b);
    context.fillRect(position.x, position.y, size.x, size.y);
  }
}

Wire it up to lib/src/game.dart:

import 'ball.dart';
import 'keyboard.dart';
import 'input_controller.dart';
import 'paddle.dart';

class Game {
  Ball ball;
  
  void start() {
    int centerX = (canvas.width ~/ 2) - (Paddle.size.x ~/ 2);
    int ballX = (canvas.width ~/ 2) - (Ball.size.x ~/ 2);
    int ballY = (canvas.height ~/ 2) - (Ball.size.y ~/ 2);

    ball = new Ball(5, new Point<int>(ballX, ballY));
    // Paddle creation logic omitted...
  }
  
  void update(Timer timer) {
    // Previous logic omitted...
    ball.update(timer);
  }

  void draw(Timer timer) {
    // Previous logic omitted...
    ball.draw(context);
  }
}

Collision Detection and Scoring

You'll notice that the ball runs off the screen, to never be seen again. With a little bit of collision detection code, we can handle all of the following cases:

  • Player 1 hit the ball. Send it back up towards Player 2.
  • Player 2 hit the ball. Send it down towards Player 1.
  • The ball went past one of the players. Give the other player a point.

Add the following to Game.update:

bool touchingPaddle1 = ball.bounds.bottom >= paddle1.bounds.top &&
    (ball.bounds.right >= paddle1.bounds.left &&
        ball.bounds.left <= paddle1.bounds.right);
bool touchingPaddle2 = ball.bounds.top <= paddle2.bounds.bottom &&
    (ball.bounds.right >= paddle2.bounds.left &&
        ball.bounds.left <= paddle2.bounds.right);
// TODO: touchingPaddle2 was checking against paddle 1

if (touchingPaddle1) {
  ball.orientation = -1;
} else if (touchingPaddle2) {
  ball.orientation = 1;
}

In the case of the ball running offscreen, it will immediately respawn in the vertical center of the screen. After a brief, one-second pause, it will move towards the other player. This keeps the game running seamlessly.

Add these fields to Game:

int score1 = 0, score2 = 0;

Add the following to Game.update:

// Check for out-of-bounds
bool outOfBounds = false;
int scoringPlayer = 1;

if (outOfBounds = (ball.bounds.bottom < worldBounds.top)) {
  // Give player 1 a point.
  score1++;
} else if (outOfBounds = (ball.bounds.top > worldBounds.bottom)) {
  // Give player 2 a point.
  score2++;
  scoringPlayer = 2;
}

if (outOfBounds) {
  // Respawn the ball after a point is given.
  // It should remain still.
  spawnBall(scoringPlayer == 1 ? paddle2 : paddle1).orientation = 0;

  // After one second, let the ball move again.
  new Timer(const Duration(seconds: 1), () {
    // Send the ball back toward the player who DIDN'T the point.
    if (scoringPlayer == 1)
      ball.orientation = -1;
    else
      ball.orientation = 1;
  });
}

Respawning the Ball

Replace the ball creation logic in start with the following:

spawnBall(paddle1);

Make sure it's placed after the initialization of paddle1, or you will wind up with a NPE.

Create the body of the method as follows:

/// Spawn the ball at a position reachable by the [player].
Ball spawnBall(Paddle player) {
    int leftBound = player.bounds.left - Paddle.size.x;
    int rightBound = player.bounds.right + Paddle.size.x;
    if (leftBound < 0) leftBound = 0;
    if (rightBound > worldBounds.width) rightBound = worldBounds.width;
    int ballX = leftBound + rnd.nextInt(rightBound - leftBound);
    int ballY = (canvas.height ~/ 2) - (Ball.size.y ~/ 2);
    return ball = new Ball(5, new Point<int>(ballX, ballY));
}

The above code will spawn the ball in a random spot on the screen, reachable by the given player. This keeps the game fair; otherwise, the ball could spawn out of range and result in automatic points for the other player!

Printing the Scores

Players will more than likely (certainly!) want to know what their score is; how else would they claim bragging rights after whooping their friends? Add the following to Game.draw:

// Draw player 1's score, 20 pixels from the left.
context
  ..font = '20px sans-serif'
  ..setFillColorRgb(255, 255, 255)
  ..fillText('Player 1: $score1', 20, 20);

// Draw player 2's score, 20 pixels from the right.
var scoreText = 'Player 2: $score2';
var metrics = context.measureText(scoreText);
context.fillText(scoreText, worldBounds.right - metrics.width - 20, 20);

Horizontal Velocity

Our Pong remake has really taken shape here. However, it's not fun. The ball forever stays in the same x-position, making it virtually impossible for the score to change. Let's shake things up by adding a little bounce.

Add a field, int horizontal, to the Ball class, initialized to 0. Modify the class' update method to the following:

void update(Timer timer) {
    position = new Point<int>(position.x + horizontal, position.y + (orientation * speed));
}

Now, the ball will shift horizontally every frame. Let's add some collision detection to prevent it from flying off-screen. When it hits the wall, it should sling right back in the opposite direction.

if (ball.position.x < worldBounds.left ||
    ball.bounds.right > worldBounds.right - ball.bounds.width) {
  // Send the ball back the other way!
  ball.horizontal *= -1;
}

When a ping-pong paddle hits a ball in real life, the velocity of the ball depends on how hard it was hit, and which part of the paddle struck at the ball. Though the strength of the hit doesn't apply to this game, we can increase the velocity based on which part of the paddle it struck. The closer to the edge, the faster the ball will go horizontally:

if (touchingPaddle1 || touchingPaddle2) {
  // Find out who hit the ball
  var paddle = touchingPaddle1 ? paddle1 : paddle2;

  // Did we hit it with the left side of the paddle?
  int middleOfPaddle = paddle.position.x + (Paddle.size.x ~/ 2);
  bool left = ball.bounds.right <= middleOfPaddle;

  // Depending on how close to the edge of the paddle we hit,
  // change the horiz. velocity by a big or small amount.
  int factor =
      ((ball.position.x - middleOfPaddle).abs() ~/ (Paddle.size.x ~/ 4));
  int magnitude = (10 * factor).round();
  if (left) magnitude *= -1;
  ball.horizontal = magnitude;
}

The AI Opponent

The last thing we need to do is make the AI opponent move. Rewrite the AIInputController as follows:

class AIInputController implements InputController {
  final int speed;
  const AIInputController(this.speed);

  @override
  Point<int> update(
      Point<int> currentPosition, Ball ball, Keyboard keyboard, Timer timer) {
    int x = currentPosition.x, diff = ball.position.x - x;

    if (diff.abs() >= speed) {
      if (x < ball.position.x) x += speed;
      else if (x > ball.position.x) x -= speed;
    }

    return new Point<int>(x, currentPosition.y);
  }
}

Granted, it's not really all that intelligent - all it does is move in the direction of the ball - but that's all you really need to do to play Pong, so it all works out in the end. No neural network necessary! Alliteration is, though.

Note that the update signature was refactored to also take a Ball as an argument.

To make the game more difficult (and thus more engaging/fun), make the AI's speed greater than your own:

paddle1 = new Paddle(
  new Point<int>(centerX, canvas.height - Paddle.size.y),
  const AIInputController(5),
);
paddle2 = new Paddle(
  new Point<int>(centerX, 0),
  const AIInputController(7), // TODO: Added speed
);

Run the game now - it's all ready to go!

The finished game.

Conclusion

Congratulations - you've successfully remade Pong! This is just the beginning though. Feel free to tweak the game on your own. Make it more difficult! Add another UserInputController that listens for the A and D keys, to make the game fun for two human players. Show it to your friends!

For further experimentation, you might want to check out StageXL, or the Phaser bindings for Dart.

Check out the source code on Github:
https://github.com/thosakwe/dart-pong

Tobe Osakwe

Tobe Osakwe

Hello, world! My name is Tobe, and I'm a first-year Comp. Sci. student at Florida State University. My hobbies include programming, writing, and music-making.

Read More