Breaking out of the routine… by doing the same thing
📅
I am not always passionate about what I do.
After more than 20 years coding in a professional capacity I can say that I like the work, but sometimes it is not motivating. In my opinion it is a positive that, nevertheless, I have enough discipline and endurance to keep at it.
This is not a complaint. Not only do I get to tackle subjects I may not have approached otherwise, I also get paid for it. Plus, more often than not, the perception of a job is heavily influenced by other factors beyond its subject matter’s potential interest: the people involved, the work conditions, etc.
It is also not an exceptional circumstance. Everyone I know has to deal with the daily grind, one way or another. Sometimes it is worth remembering it, even if it does not make it any easier to bear. There is a saying in Spanish that goes “mal de muchos, consuelo de tontos”, which roughly means that when some sorrow or tragedy is shared by many only fools take solace in that fact. It appears to have a common sentimient with “solamen miseris socios habuisse doloris”, from Christopher Marlowe’s Doctor Faustus, although its most known translation, “misery loves company”, would seem to have a different nuance.
But I digress. All of this is a long-winded way of saying that it is reasonable and normal to feel stuck in a rut, occasionally. To get out of mine, I decided to work on a little project that could be fun, would not take up a lot of time, and provide some sense of performance (or lack thereof) of the simple collision detection code I wrote about.
Basically, I wanted an excuse to have this line at the top of whatever I coded next:
import { Box } from "./Box.js";
So, I came up with…
A Paddle and Ball game
Although Pong is the primordial example of this kind of game, I have more affinity with games like Breakout or Arkanoid. I ignore how aggresively are Atari’s and Taito’s copyrights pursued so, to err on the side of caution, suffice it to say that the mention of those games is provided exclusively for historical reference.
What I will program is an interactive demonstration of possible uses the Box
class. Specifically, there will be four kind of boxes based on their behaviour:
A playing field.
A paddle that can move only in one direction within the bounds of the playing field, controlled by the user.
A ball moves at a certain velocity and rebounds if it collides with anything other than the border of the playing field behind the paddle.
A series of bricks that “explode” when the ball hits them.
My intention is to develop the code up to a working prototype, but I do not intend to write about the full implementation. Instead, I am going to outline the logic behind some of the choices I make.
Extending the Box
class
All of the listed elements have one attribute that the Box
class does not provide: colour. Said property can be added to the Box
definition, or a new class can be used. Opting for the second option, the resulting class will be the building block of the game:
/**
* A representation of a rectangle with a colour.
* @class
* @augments module:box~Box
*/
class Block extends Box {
/**
* The block's colour.
* @type {String}
* @default #027cb0
*/
colour = "#027cb0";
/**
* Create a Block.
* Origin of coordinates is at the top left, with x increasing to the right
* and y increasing downwards.
* @param {Number} [x=0]
* @param {Number} [y=0]
* @param {Number} [width=0] Cannot be a negative number.
* @param {Number} [height=0] Cannot be a negative number.
* @param {String} [colour=#027cb0] A colour value.
*/
constructor( x = 0, y = 0, width = 0, height = 0, colour = "#027cb0" ) {
super( x, y, width, height );
this.colour = colour;
}
}
There are two methods that I would include in the base Box
class:
/**
* Calculate the box resulting from an intersection of two boxes.
* @param {module:box~Box} box1
* @param {module:box~Box} box2
* @return {(module:box~Box)?}
* @example <caption>Colliding boxes</caption>
* const box1 = new Box( 0, 0, 20, 20 );
* const box2 = new Box( 10, 10, 20, 20 );
* const box3 = Box.intersect( box1, box2 );
* box3.bounding; // is `{ x: 10, y: 10, width: 10, height: 10 }`
* @see {@link module:box~Box#intersect|box.intersect() dynamic method}
*/
static intersect( box1, box2 ) {
if ( !Box.areColliding( box1, box2 ) ) { return null; }
const minX = Math.max( box1.minX, box2.minX );
const minY = Math.max( box1.minY, box2.minY );
const maxX = Math.min( box1.maxX, box2.maxX );
const maxY = Math.min( box1.maxY, box2.maxY );
return Box.createFromObject( {
x: minX,
y: minY,
width: ( maxX - minX ),
height: ( maxY - minY )
} );
}
/**
* Determine if the box is completely inside `anotherBox`.
* @param {module:box~Box} anotherBox
* @return {Boolean}
* @example <caption>Box in a box</caption>
* const box1 = new Box( 0, 0, 30, 30 );
* const box2 = new Box( 10, 10, 10, 10 );
* box2.isInside( box1 ); // is `true`
* box1.isInside( box2 ); // is `false`
*/
isInside( anotherBox ) {
return (
( this.minX >= anotherBox.minX )
&& ( this.maxX <= anotherBox.maxX )
&& ( this.minY >= anotherBox.minY )
&& ( this.maxY <= anotherBox.maxY )
);
}
Defining a game controller
It will all be bundled in a class, with mostly private proerties and methods:
/**
* A controller for an arcade game.
* @class
*/
class PaddleAndBallGame {
// All the code goes here.
}
Firstly, there need to be resources to render the game on:
/**
* The `canvas` element to render the game in.
* @type {HTMLCanvasElement?}
*/
#canvas = null;
/**
* The [`canvas`]{@link PaddleAndBallGame#canvas}' 2d context.
* @type {CanvasRenderingContext2D?}
*/
#context = null;
For a moment I was tempted to use SVG
to render the game, mainly for the sake of it, but there were two reasons I had to “shake off” to stick to using the canvas
element. The first one was accesibility, if only for the possibility of scaling up the graphic without pixelisation. The second one is that I would like to confirm or disprove that the performance of the game would be worse. That is my gut feeling but:
“I’ve been thinking with my guts since I was fourteen years old, and frankly speaking, between you and me, I have come to the conclusion that my guts have shit for brains.”
― Nick Hornby, High Fidelity.
My time for this project being finite, it is something I hope to test at another moment.
Secondly, there are the variables to handle the field, the paddle, the ball and its movement, and the bricks to be destroyed:
/**
* The `Block` describing the playing field.
* @type {Block?}
*/
#field = null;
/**
* The `Block` describing the paddle.
* @type {Block?}
*/
#paddle = null;
/**
* The `Block` describing the ball.
* @type {Block?}
*/
#ball = null;
/**
* The ball's velocity vector.
* @type {Object}
* @default { x: 2.8284, y: -2.8284 }
*/
#ballVelocity = {
x: 4 * Math.cos( Math.PI / 4 ),
y: -4 * Math.sin( Math.PI / 4 )
};
/**
* The collection of `Block` bricks to be destroyed.
* @type {Block[]}
*/
#bricks = [];
Thirdly, running everything as fast as possible is not always the best idea, specially regarding games (see late 80s/early 90s turbo button). Only a couple of variables are required to implement a framerate limit:
/**
* Minimum duration of a frame. Inversely proportional to the maximum framerate.
* For example, a 60 FPS limit implies a minFrameDuration of 1000 / 60 ≈ 16.667
* @type {Number}
* @default 16.667
*/
#minFrameDuration = 1000 / 60; // Inverse of 60 frames per thousand miliseconds
/**
* The last timestamp a frame was processed at.
* @type {Number}
*/
#previousFrameTimestamp = 0;
Fourthly, the animation loop will use the requestAnimationFrame
method, that returns a request identifier which will be tracked:
/**
* The requestAnimationFrame identifier.
* @type {Number}
*/
#requestAnimationFrameId = 0;
Fifthly, there are the variables to keep track of the game’s global state.
/**
* Flags when the game is paused, i.e., when ball and paddle do not move.
* Plus, an informational text overlay will be shown, with the game's stats
* and instructions to continue playing.
* @type {Boolean}
*/
#isPaused = true;
/**
* Flags when the game is over, regardless of the cause.
* @type {Boolean}
*/
#isGameOver = false;
In last place, the game will be controlled with the mouse. Other options can be programmed, but a simple control scheme can be: using the mouse click to start/pause the game and following the mouse position with the paddle. This requires two event handlers:
/**
* The `mouseup` event handler.
* @memberof PaddleAndBallGame#
* @access private
* @function handleMouseUp
* @param {MouseEvent} mouseUpEvent
*/
#handleMouseUp = function( mouseUpEvent ) { };
/**
* The `mousemove` event handler.
* @memberof PaddleAndBallGame#
* @access private
* @function handleMouseMove
* @param {MouseEvent} mouseMoveEvent
*/
#handleMouseMove = function( mouseMoveEvent ) { };
There can be addittions but, for the time being I am only going to work with these elements.
Setting up the canvas
Instead of creating a canvas
element, I assume that one will be provided in the class’ constructor. A dedicated method can have that element set up for the game:
/**
* Set up the `canvas` element.
* @memberof PaddleAndBallGame#
* @access private
* @function setUpCanvasAndContext
* @param {HTMLCanvasElement} canvas
*/
#setUpCanvasAndContext( canvas ) {
const boundingClientRect = canvas.getBoundingClientRect();
canvas.width = boundingClientRect.width;
canvas.height = boundingClientRect.height;
this.#canvas = canvas;
const contextAttributes = { alpha: false, desynchronized: false };
this.#context = this.#canvas.getContext( "2d", contextAttributes );
}
Initialising field, paddle, ball, and bricks
A method for one line of code that will be used once is overkill. I will write it as an example of the simplest initialisation, but will not use it.
/**
* Create the game's field.
* @memberof PaddleAndBallGame#
* @access private
* @function createField
* @param {String} [colour=#000]
*/
#createField( colour ) {
this.#field = new Block(
0, 0, this.#canvas.width, this.#canvas.height, colour
);
}
The paddle is not much more complicated: it is a block proportional to the canvas’ width, with an elongated aspect ratio, and centered horizontally some space over the bottom of the canvas.
/**
* Create the game's paddle.
* @memberof PaddleAndBallGame#
* @access private
* @function createPaddle
* @param {String} [colour=#f90]
*/
#createPaddle( colour = "#f90" ) {
const width = this.#canvas.width / 10; // In proportion to the canvas' width
const height = width * 9 / 32; // Use an elongated aspect ratio
this.#paddle = new Block(
( this.#canvas.width - width ) / 2, // Centered horizontally
this.#canvas.height - height * 3, // Leaving some space from the bottom
width,
height,
colour
);
}
Similarly, the ball’s instantiation follows a simple logic: create a square block (in this case, proportional to the canvas’ smallest dimension) and position it centered over the paddle. There is also the ball’s velocity, which can be proportional to the playing area’s height instead of a preset value.
/**
* Create the game's ball and set its speed.
* Requires [`createPaddle`](PaddleAndBallGame#createPaddle) to have been run first.
* @memberof PaddleAndBallGame#
* @access private
* @function createBall
* @param {String} [colour=#fff]
*/
#createBall( colour = "#fff" ) {
const radius = Math.min( this.#canvas.width, this.#canvas.height ) / 60;
const length = Math.sqrt( Math.PI * Math.pow( radius, 2 ) );
this.#ball = new Block(
this.#paddle.x + ( this.#paddle.width - length ) / 2, // Center over paddle
this.#paddle.y - length - 1, // 1px separation to avoid collision
length,
length,
colour
);
const ballVelocityMagnitude = this.#canvas.height / 60;
const piFourth = Math.PI / 4;
this.#ballVelocity = {
x: ballVelocityMagnitude * Math.cos( piFourth ),
y: -ballVelocityMagnitude * Math.sin( piFourth )
};
}
Last, but not least, the game requires some bricks to destroy. Since same coloured bricks are boring, I will use the generateColourCycle
method to get a gradient of colours. That way each row of bricks will have its distinctive colour, and the stacked rows will have a chromatic progression.
/**
* Create the bricks' boxes.
* @memberof PaddleAndBallGame#
* @access private
* @function createBricks
* @param {Number} [numberOfColumns=15]
* @param {Number} [numberOfRows=6]
*/
#createBricks( numberOfColumns = 15, numberOfRows = 6 ) {
this.#bricks = [];
const bricksWidth = this.#canvas.width * 0.75; // Use 75% of the canvas' width
const bricksXOffset = ( this.#canvas.width - bricksWidth ) / 2;
const bricksYOffset = this.#canvas.height * 0.05; // Leave 5% vertical space
const brickWidth = bricksWidth / numberOfColumns; // Individual brick width
const brickHeight = brickWidth * 9 / 16; // Use a convenient aspect ratio
const numberOfColours = Math.floor( 1.25 * numberOfRows );
const colours = PaddleAndBallGame.#generateColourCycle( numberOfColours );
for( let r = numberOfRows - 1; r >= 0; r = r - 1 ) {
for ( let c = numberOfColumns - 1; c >= 0; c = c - 1 ) {
this.#bricks.push( new Block(
bricksXOffset + c * brickWidth,
bricksYOffset + r * brickHeight,
brickWidth,
brickHeight,
colours[ r ]
) );
}
}
}
Putting it all together:
/**
* Initialise all of the game's required elements.
* @memberof PaddleAndBallGame#
* @access private
* @function createElements
*/
#createElements() {
this.#field = new Block(
0, 0, this.#canvas.width, this.#canvas.height, "#000"
);
this.#createPaddle(); // Default colour: #f90
this.#createBall(); // Default colour: #fff
this.#createBricks(); // Default columns×rows: 15×6
}
Painting on the canvas
The canvas
will be used to render all the elements of the game and, when the game is paused, information pertaining the game state:
/**
* Draw an overlay with information relevant to the game's state.
* @memberof PaddleAndBallGame#
* @access private
* @function paintPauseOverlay
*/
#paintPauseOverlay() {
if ( !this.#isPaused ) { return; }
if ( !this.#isGameOver ) {
// TODO: Show current score and instructions to resume game. For example:
// "There are 37 out of 90 bricks left."
// "Click on the game to play."
return;
}
if ( this.#bricks.length === 0 ) {
// TODO: Show "YOU WON!" message, and instructionts to play again.
return;
}
// TODO: Show "GAME OVER" message, and instructionts to play again.
}
/**
* Draw all elements on the canvas.
* @memberof PaddleAndBallGame#
* @access private
* @function paintFrame
*/
#paintFrame() {
this.#context.fillStyle = this.#field.colour;
this.#context.fillRect( ...this.#field.rect );
this.#context.fillStyle = this.#paddle.colour;
this.#context.fillRect( ...this.#paddle.rect );
this.#context.fillStyle = this.#ball.colour;
this.#context.fillRect( ...this.#ball.rect );
this.#context.strokeStyle = this.#field.colour;
for( let b = this.#bricks.length - 1; b >= 0; b = b - 1 ) {
const brick = this.#bricks[ b ];
// Paint brick body.
this.#context.fillStyle = brick.colour;
this.#context.fillRect( ...brick.rect );
// Add contour to visually separate brick from adjacent elements.
this.#context.beginPath();
this.#context.rect( ...brick.rect );
this.#context.stroke();
}
this.#paintPauseOverlay();
}
The paintPauseOverlay
method is not developed because it is an arbritrary design decision, and possibly an inconvenient one. The information to be shown is mostly text so it could be better handled with DOM elements, rather than the canvas, even if it just for accesibility. I was tempted, again, to switch to using SVG, which has its downsides but is head and shoulders above canvas
in this department. Besides, adjusting text to boxes in a CanvasRenderingContext2D
requires using the TextMetrics
interface which is, for me, a bit finicky.
There is one possible simplification on the previous code: if the field is not going to change colour, the strokeStyle
could be established at the (yet to be defined) context
’s initialisation. The performance benefits are dubious, and the logic might be more difficult to follow (more on that later) so, in this instance, I would opt for contextual coherence.
Controlling the FPS
As specified, the paintFrame
method will be called with a framerate limit:
/**
* Handle the main animation loop.
* @memberof PaddleAndBallGame#
* @access private
* @function handleAnimationFrame
* @param {Number} timestamp
*/
#handleAnimationFrame( timestamp ) {
const handler = this.#handleAnimationFrame.bind( this );
this.#requestAnimationFrameId = window.requestAnimationFrame( handler );
if ( timestamp - this.#previousFrameTimestamp < this.#minFrameDuration ) {
return;
}
this.#previousFrameTimestamp = timestamp;
// TODO: Update the game's state to the next frame
this.#paintFrame();
}
Even though there are quite a few TODO
s in the code, I am going to skip to the class’ constructor so the code can run.
Instantiation
Having bundled all the logic in convenient methods, the logic of the constructor is simple:
/**
* Create a new Paddle and Ball game.
* @param {HTMLCanvasElement} canvas The `canvas` where the game will be rendered.
*/
constructor( canvas ) {
if ( !( canvas instanceof HTMLCanvasElement ) ) {
console.error( "Game requires a canvas element" );
return;
}
this.#setUpCanvasAndContext();
this.#createElements();
// TODO: Bind inputs to control the paddle and pause/unpause
const handler = this.#handleAnimationFrame.bind( this );
this.#requestAnimationFrameId = window.requestAnimationFrame( handler );
}
Running new PaddleAndBallGame( canvas )
will paint the game’s elements in said canvas
. Now we have to make them move and behave as expected.
Adding the game logic
The TODO
line in handleAnimationFrame
will be replaced with a call to a method that will implement the logic described at the beginning:
/**
* Update the state of the game's objects.
* @memberof PaddleAndBallGame#
* @access private
* @function updateGameState
*/
#updateGameState() {
if ( this.#isPaused ) { return; }
// Move ball
this.#ball.x += this.#ballVelocity.x;
this.#ball.y += this.#ballVelocity.y;
// TODO: Check and account for ball vs. wall collision,
// updating this.#isGameOver. Something like:
// this.#isGameOver = this.#processBallVsWallCollision();
if ( this.#isGameOver ) {
this.#isPaused = true;
return;
}
// TODO: Check and account for ball vs. paddle collision. Something like:
// this.#processBallVsPaddleCollision();
// TODO: Check and account for ball vs. brick collision,
// updating this.#isGameOver. Something like:
// this.#isGameOver = this.#processBallVsBrickCollision();
if ( this.#isGameOver ) { this.#isPaused = true; }
}
The order of the logic tries to do the least amount of operations and exit as early as possible. Thus, first, it checks for interactions between two elements, the ball and the field, and stops as soon as it can. Second, it checks two other elements, the ball and the paddle. Third and last, it checks the interactions between the ball and the bricks.
I cannot prove that the order of the second and third steps adheres strictly to the previously established guideline. Even though it is practically irrelevant, if the order of those two steps were reversed, the last brick collision could signal an “early” exit of the method, saving one ball vs. paddle intersection check. However, the reason to debate the order is that, as it will be shown later on, the logic behind the collision ball and paddle is more complicated than the one between ball and bricks.
The simplest logic is the ball vs. wall collision: if the latest movement of the ball has put it outside the playing field, invert the velocity vector’s axis pertinent to the collision and revert the move, or call the game over if the ball has hit the bottom of the field.
/**
* Check if the ball is colliding with a wall and react accordingly.
* @memberof PaddleAndBallGame#
* @access private
* @function processBallVsWallCollision
* @return {Boolean} `true` if the ball has hit the bottom of the field.
*/
#processBallVsWallCollision() {
// Early exit if the ball has not exceeded the field limits.
if ( this.#ball.isInside( this.#field ) ) { return false; }
// Check for GAME OVER condition
if ( this.#ball.maxY >= this.#field.maxY ) { return true; }
// Check for collision against the left or right boundaries
if (
( this.#ball.minX <= this.#field.minX )
|| ( this.#ball.maxX >= this.#field.maxX )
) {
this.#ballVelocity.x *= -1;
this.#ball.x += this.#ballVelocity.x; // Put ball back inside field
}
// Check for collision against the top boundary
if ( this.#ball.minY <= this.#field.minY ) {
this.#ballVelocity.y *= -1;
this.#ball.y += this.#ballVelocity.y; // Put ball back inside field
}
}
The logic behind the behaviour when the ball collides with the bricks is not dissimilar to the previous one, with two differences.
On one hand, the ball may intersect with multiple bricks. To avoid overcomplicating this condition, a simple criteria can be established: only the brick with the biggest intersection area with the ball will be considered.
On the other, the logic for the ball’s movement change of direction is shifted from inside to outside.
/**
* Check if the ball is colliding with a brick and react accordingly.
* If the ball is colliding with multiple bricks, it only considers the brick
* that has a bigger intersection area.
* @memberof PaddleAndBallGame#
* @access private
* @function processBallVsBricksCollision
* @return {Boolean} `true` if the all bricks have been destroyed.
*/
#processBallVsBricksCollision() {
const numberOfBricks = this.#bricks.length;
if ( numberOfBricks === 0 ) { return true; } // Signal Game Over
// Determine the brick that has the biggest intersection area with the ball.
let collidedBrickIdx = -1;
let collisionArea = 0;
for( let b = numberOfBricks - 1; b >= 0; b = b - 1 ) {
const intersection = this.#bricks[ b ].intersect( this.#ball );
if ( !intersection ) { continue; }
if ( intersection.area >= collisionArea ) {
collidedBrickIdx = b;
collisionArea = intersection.area;
}
}
// If none is found, the ball has not hit any bricks.
if ( collidedBrickIdx == -1 ) { return false; }
// Revert the ball's position.
this.#ball.x -= this.#ballVelocity.x;
this.#ball.y -= this.#ballVelocity.y;
// Determine the rebound for the ball's position relative to the brick.
const brick = this.#bricks[ collidedBrickIdx ];
if ( this.#ball.isLeft( brick ) || this.#ball.isRight( brick ) ) {
this.#ballVelocity.x *= -1;
}
if ( this.#ball.isAbove( brick ) || this.#ball.isBelow( brick ) ) {
this.#ballVelocity.y *= -1;
}
// Remove brick from collection.
this.#bricks.splice( collidedBrickIdx, 1 );
// Signal Game Over (when one brick was left and has been destroyed)
return ( numberOfBricks === 1 );
}
The logic for the collision with the paddle is a mixture of the two previous cases, with a twist.
There is only one intersection to take into account, and the ball’s movement direction change will be relative to the position it is colliding with the paddle, but the angle will change depending on the point of impact.
/**
* Check if the paddle has hit the ball (or viceversa) and react accordingly.
* The paddle will change the ball's movement direction in a degree relative
* to the impact position.
* @memberof PaddleAndBallGame#
* @access private
* @function processBallVsPaddleCollision
* @return {Boolean} `true` if paddle and ball have collided
*/
#processBallVsPaddleCollision() {
const intersection = this.#ball.intersect( this.#paddle );
// Early exit if the ball has not hit the paddle (or viceversa).
if ( !intersection ) { return false; }
// Push the ball above the paddle
this.#ball.y -= intersection.height;
// Determine constant values
const DEG2RAD = Math.PI / 180; // Truly constant, hence uppercase
const velocityMagnitude = Math.sqrt(
Math.pow( this.#ballVelocity.x, 2 )
+ Math.pow( this.#ballVelocity.y, 2 )
);
// Determine reference x coordinates
const halfBallWidth = this.#ball.width / 2;
const ballMiddleX = ( this.#ball.x + halfBallWidth );
const halfPaddleWidth = this.#paddle.width / 2
const paddleMiddleX = ( this.#paddle.x + halfPaddleWidth );
// Determine in which side of the paddle is the point of impact
const hasHitLeft = ballMiddleX < paddleMiddleX;
// Determine the new direction's angle range. Remember, `y` values increase
// from the top to the bottom, so the angles that point "up" in the screen
// are those that have a negative sine, between 180° and 360°.
const minAngle = ( hasHitLeft ? 200 : 290 ) * DEG2RAD;
const maxAngle = ( hasHitLeft ? 250 : 340 ) * DEG2RAD;
// Establish the extrema of the ball's position relative to the segment
const segmentMinX = hasHitLeft
? ( this.#paddle.x - halfBallWidth )
: paddleMiddleX
;
const segmentMaxX = hasHitLeft
? paddleMiddleX
: ( this.#paddle.maxX + halfBallWidth )
;
let perunit = ( ballMiddleX - segmentMinX ) / ( segmentMaxX - segmentMinX );
if ( perunit < 0 ) { perunit = 0; }
if ( perunit > 1 ) { perunit = 1; }
// LERP
const angle = minAngle + perunit * ( maxAngle - minAngle );
this.#ballVelocity.x = velocityMagnitude * Math.cos( angle );
this.#ballVelocity.y = velocityMagnitude * Math.sin( angle );
return true;
}
This method is a bit long. Anything that exceeds approximately 40 lines, including comments, is harder to fit in an average editor window. I would strongly consider splitting this method for clarity.
Yes, some can have ultra-wide 34″ panels set up vertically. I know someone who uses a 48″ TV as a PC monitor. I would not consider those cases average. Those people do not concern me. I worry about myself, or any other poor soul, when the only device available to review the code is a borrowed 15″ laptop.
Without taking it to that extreme, smaller code chunks can be better fitted in tiled IDEs that may have some of its screen space taken up by terminals, file explorers and other tools.
There are other potential benefits, like reusability and testing. Even though the linear interpolation formula is one line, it is not uncommon nor unreasonable to define the lerp
function. After all, it may be used more than once. Plus, testing said function ensures it having the expected behaviour wherever it may be used, instead of risking some mistake when typing the formula.
The same could be applied to calculating the per-unit value.
Mouse control scheme
The proposed control scheme is as simple as it can get.
On one hand, the mouse click starts/pauses the game. If the game was over, it re-instantiates all the elements and starts again. It is unelegant, since it needlessly creates a new field, and creates new paddle and ball when the only requirement is positioning them in their starting positions.
/**
* Start/pause the game.
* @memberof PaddleAndBallGame#
* @access private
* @function onMouseUp
* @param {MouseEvent} mouseUpEvent
*/
#onMouseUp( mouseUpEvent ) {
// Pause/unpause
if ( !this.#isGameOver ) {
this.#isPaused = !this.#isPaused;
return;
}
// Re-position all the game elements by re-creating them. Not very elegant.
this.#createElements();
// Restart the game.
this.#isGameOver = false;
this.#isPaused = false;
}
On the other, the paddle will track the position of the paddle:
/**
* Move the paddle following the mouse.
* @memberof PaddleAndBallGame#
* @access private
* @function onMouseMove
* @param {MouseEvent} mouseMoveEvent
*/
#onMouseMove( mouseMoveEvent ) {
if (
this.#isPaused
|| (
this.#ball.isBelow( this.#paddle )
&& (
this.#ball.isLeft( this.#paddle )
|| this.#ball.isRight( this.#paddle )
)
)
) {
return true;
}
const maxX = this.#canvas.width - this.#paddle.width;
const canvasClientX = this.#canvas.getBoundingClientRect().x;
let x = mouseEvent.clientX - canvasClientX - this.#paddle.width / 2;
if ( x < 0 ) { x = 0; }
if ( x > maxX ) { x = maxX; }
this.#paddle.x = x;
}
The method that will be called in the constructor binds all the events to their respective handlers:
/**
* Bind events to their appropriate handlers.
* @memberof PaddleAndBallGame#
* @access private
* @function addEventListeners
*/
#addEventListeners() {
this.#handleMouseUp = this.#onMouseUp.bind( this );
window.addEventListener( "mouseup", this.#handleMouseUp );
this.#handleMouseMove = this.#onMouseMove.bind( this );
window.addEventListener( "mousemove", this.#handleMouseMove );
}
Keeping track of the handlers allows for them to be removed, for example, if we want to dispose of the game.
Disposing
/** A method to free all the game's resources. */
dispose() {
window.cancelAnimationFrame( this.#requestAnimationFrameId );
window.removeEventListener( "mousemove", this.#handleMouseMove );
window.removeEventListener( "mouseup", this.#handleMouseUp );
}
Possible improvements
There are several values that are static or constant once initiated. For example, the degrees to radians convertion factor is always the same:
/**
* Degrees to radians convertion factor.
* @type {Number}
*/
static #DEG2RAD = Math.PI / 180;
Then again, this value is only used to convert the minimum and maximum angles when the ball bounces from the paddle. Since those values are also constant, they could be calculated once and made available through a static variable.
/**
* Rebound angle ranges, in radians, for ball vs. paddle collision.
* Taking into account `y` values point downwards in canvas, if the ball is
* bouncing off the left-hand side of the paddle, if will rebound with an
* angle between 200° and 250°. If it's the right-hand side, the angle range
* is 290° to 340°.
* @type {Object}
*/
#paddleReboundAngleRange = {
left: [ 200 * Math.PI / 180, 250 * Math.PI / 180 ],
right: [ 290 * Math.PI / 180, 340 * Math.PI / 180 ]
};
The logic of the the game is such that the magnitude of the velocity is invariant, too. There is no need to calculate it each time the ball and the paddle collide. Instead, there can be a private variable to store its value:
/**
* The ball's velocity vector's modulus.
* @type {Number}
* @default 4
*/
#ballVelocityMagnitude = 4;
/**
* The ball's velocity vector. Depends on [`ballVelocityMagnitude`]{@link PaddleAndBallGame#ballVelocityMagnitude}.
* @type {Object}
* @see {@link PaddleAndBallGame#ballVelocityMagnitude}
* @default { x: 2.8284, y: -2.8284 }
*/
#ballVelocity = {
x: this.#ballVelocityMagnitude * Math.cos( Math.PI / 4 ),
y: -this.#ballVelocityMagnitude * Math.cos( Math.PI / 4 ),
};
This value would be instantiated in the createBall
method and used in the processBallVsPaddleCollision
method, which also uses some values that are derived from properties of objects that do not change once they are created. For example, both the ball’s and the paddle’s half-widths are calculated, but the result is always the same, so their values could be cached in specific variables:
/**
* Half the width of the paddle.
* @type {Number}
* @see {@link PaddleAndBallGame#createPaddle}
*/
#paddleHalfWidth = 0;
/**
* Half the width of the ball.
* @type {Number}
* @see {@link PaddleAndBallGame#createBall}
*/
#ballHalfWidth = 0;
As commented, both of these values would have to be assigned in the createPaddle
and createBall
methods, respectively.
The benefits of using these specific cached values can be contested or, at least, weighed against the cost of maintaining and keeping track of more code, in separate regions, for something that occurs infrequently in the case of processBallVsPaddleCollision
. However, on the subject of more code, these cached values would help reduce the number of lines that compose the method. See my previous diatribe against long methods. Whilst I am being self-referential, see also my previous argument for code clarity.
There is, however, at least one point where some almost-constant values can be required multiple times per second, and that is the on the onMouseMove
event: “The frequency rate of events while the pointing device is moved is implementation-, device-, and platform-specific”, according to the W3C Working Draft on UI Events. The following standalone code (as in, not in the PaddleAndBallGame
class) can approximate how many mousemove
events are fired per second:
let isCounting = false;
let counter = 0;
let start = 0;
// Click on window to start counting
window.addEventListener( "mouseup", function( mouseUpEvent ) {
counter = 0;
start = performance.now();
isCounting = true;
console.log( "Start", start );
} );
// Count each `mousemove` event
window.addEventListener( "mousemove", function( mouseMoveEvent ) {
if ( !isCounting ) { return; }
counter++;
} );
// When the mouse exits the window area, stop counting and log statistics
window.addEventListener( "mouseout", function( mouseOutEvent ) {
if ( !isCounting ) { return; }
isCounting = false;
const end = performance.now();
console.log( "End", now );
console.log( "Counter", counter );
console.log( "Average per second", 1000 * counter / ( end - start ) );
} );
In my tests, Chrome registered ≈120 events per second, while Firefox showed ≈60. These figures only illustrate the disparity between environments. In all likelyhood, even in the higher frequencies, the browser will have marginal gains using cached values, but that ought to be tested.
The main argument of increased maintenance cost is more evident when the logic of the game is developed further. For example, in Atari’s Breakout, the “paddle shrinks to one-half its size after” a certain event. If the game were to implement a similar behaviour, besides changing the paddle’s Block
width
, the paddleHalfWidth
value would have to be updated, too. Unfortunately, even though the Block
class could incorporate a read-only halfWidth
property, I think it is not possible to add an override of the width
setter that updates said property, as well.
Speaking of getters and setters, the class could have public read-only properties that could be used outside the scope of the class, like the numbers for total bricks and remaining ones, to show outside the canvas. A public speed
property could regulate the velocityMagnitude
.
Also, the game could have alternative inputs, like controlling the paddle with the keyboard and/or touch events.
Having these ideas reveal that a part of the original purpose of this project has been fullfiled: I would not want to continue it if I were not fun for me, and thinking of adding more stuff to add is only enabled by the fact that a browser can handle performantly a few dozens of Block
(and, through extension, Box
) objects rendered in a canvas
elements. The “not taking up a lot of time” starts to be compromised, so this is a good place to stop, for now.
Play it!
A working version of this code can be played at bb.galdecoa.com.