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 pongstagehand 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.
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.
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.
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.
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 1if (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-boundsbool 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!
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