Tools Needed:
- NodeJS and NPM
- any modern web browser
- any text editor
Full Source: GitHub.
Framework: Collider.JAM, runnpm i -g collider.jam
to install.
Intro
Pong was created in 1972 by Allan Alcorn, the 2nd non-founding employee of Atari, as an exercise. Nolan Bushnell, the Atari co-founder, reasoned that an electronic version of ping-pong will be a perfect way to get familiar with arcade game development. Especially considering that a similar concept had already been implemented in Magnavox Odyssey.
Little did they know, that Pong would become a booster that brought Atari into a multi-billion corporation orbit.
Today, Pong is considered to be an arcade classic. Also, with its iconic gameplay, it still works as a good exercise in game development.
Thus, this tutorial covers all the steps necessary to implement a web-based clone of Pong with Collider.JAM and JavaScript.
Core Concepts
We need at least 3 components to implement a viable Pong version:
- a puck moved by its speed vector
- two paddles controlled by players
- a score counter
There is also an invisible, but nevertheless present, simple physics simulation to detect the puck collisions with the paddles and the edges.
The game scene is going to be implemented in a single mod.
Inside, we will place the /dna folder to keep our prototypes.
Collider.JAM has the concept of mods, which can represent modules, plugins, scenes, levels, game layers, and game states among other things.
You can view a mod as a minigame with its own code, structure, resources... It can be the main menu or a player configuration screen, or maybe a map screen.
Usually the mod structure is determined by the corresponding directory. A mod has it's own dna, lab, trap and other folders.
There are going to be only two prototypes in /dna - Puck and Paddle.
A single object in /lab/score.js will represent the score indicator. The indicator is a singleton existing for the whole game lifespan. So instead of putting it in dna and creating in setup(), we just put it directly in lab.
The /res folder will keep all sound effects. We don't need any bitmap or vector graphics in this tutorial, since everything is drawn by code.
The trap folder keeps event handlers. Control events like keyDown and keyUp as well as custom events like newGame and spawnPunk are trapped here.
Note, that a file location and name are important in Collider.JAM.
For example, there won't be any score if score.js will be placed outside of /lab. Objects in /lab are considered to be "alive".
Puck and Paddle MUST be placed in /dna for similar reason. Constructors and factories are expected to be in /dna. Constructor names MUST be capitalized - that is how Collider.JAM understands it deals with constructors.
So be careful with the file and object naming and placement. It often affects the object's behavior.
Foundation
Create a folder named pong.mod. The .mod extension is necessary for Collider.JAM to determine the root of the project.
Once created, you can cd into it:
cd pong.mod
And run the empty mod with:
jam -d
The -d flag tells Collider.JAM to enable development and debug features.
It is very useful in development.
Make sure you have Collider.JAM installed before starting this tutorial. The easiest way is to run
npm i -g collider.jam
in a terminal emulator.Collider.JAM makes all kind of JavaScript magic to hide the boilerplate and make development smooth. You can find more about Collider.JAM in online documentation
Puck
This prototype is located in /dna/Puck.js and implements the puck behavior and visuals.
defaults
Let's declare some constants and default values:
// dna/Puck.js
const MAX_SPEED = 1500
const HIT_ACCELERATION = 1.07
const df = {
x: 0,
y: 0,
r: 10,
hold: 1,
speed: 100,
}
constructor
We'll use the class syntax to declare our prototype. It's concise and works well except for a few special cases:
// dna/Puck.js
// ...
class Puck {
constructor(st) {
augment(this, df, st) // set default and init values
// select a random direction
let fi = ( rnd() * .4*PI - .2*PI ) - ( PI * floor(rnd(2)) )
this.dx = cos(fi)
this.dy = sin(fi)
}
}
The augment() function is provided by Collider.JAM and augments the target object with values from source objects.
We assign default values from df first and init values form st later (so the init values can override the default ones). It is a common idiom in Collider.JAM object initialization - declare an object with default values, pass an object with init values, and then augment the constructed object with both.
behavior
The puck needs the evo(dt) function to determine its behavior and the draw() function to define its look.
The evo(dt) is the most complex one:
evo(dt) {
if (lab.score.countdown) return // the counter is still on
// we need a hold timer to prevent the puck
// from moving the moment it's been created
if (this.hold > 0) {
// hold on
this.hold -= dt
// start the movement when the time is up
if (this.hold <= 0) lib.sfx(res.sfx.slide, .5)
return
}
// save previous x/y
const px = this.x
const py = this.y
// move
let touched = false
this.x += this.dx * this.speed * dt
this.y += this.dy * this.speed * dt
// detect edge collisions
const r = this.r
if (this.x < r) {
// hit the left edge
kill(this)
trap('score', 'right')
} else if (this.x > rx(1)-r) {
// hit the right edge
kill(this)
trap('score', 'left')
}
if (this.y < r) {
// reflect from the top edge
this.y = r
this.dy *= -1
touched = true
lib.sfx(res.sfx.boing, .2)
} else if (this.y > ry(1)-r) {
// reflect from the bottom edge
this.y = ry(1)-r
this.dy *= -1
touched = true
lib.sfx(res.sfx.boing, .2)
}
// detect collision with paddles
const puck = this
lab._ls.forEach(e => {
if (e.touch && e.touch(puck)) {
touched = true
this.speed = min(this.speed * HIT_ACCELERATION, MAX_SPEED)
}
})
if (touched) {
// move back to previous coordinates
this.x = px
this.y = py
}
}
First, we need two guards to prevent evolution from happening while the game countdown is still on or we are holding the puck. If the countdown value in lab.score object is anything, but 0, we skip the evolution. We let the score object itself handle the countdown behavior.
The hold value tracks the time left to keep the puck frozen - we don't want to launch the puck the moment it's created. We have to reduce the timer until it's 0 or less, then we play a sound effect and the puck evolution begins.
The evolution itself has two main components - movement and collision detection.
We preserve coordinates before the movement to jump back in case of collision. That way we can prevent the tunnel effect through the edges and paddles. It is a crude and not exactly precise approach, but it works fine in our case.
The collision detection itself is split into two phases - collision with the edges and collision with the paddles.
The edge collision is handled by Puck locally. Notice the difference between the left/right and the top/bottom edges. For the top and the bottom edge we have to reflect the puck vector over the Y-axis:
this.dy *= -1
and play the boing sound effect.
In the case of the left or the right edge, we kill the puck and score the corresponding side. The score logic is moved out into an external trap. It is a game-level event and it is a good practice to keep it in a separate function instead of being hidden in Puck.
The collision detection with paddles is different since it is handled mostly by the paddles.
We iterate over all nodes in /lab and find the ones with touch (we assume that touch() will be a function here).
It means the touch() function MUST be defined on all entities the puck can touch (paddles in our case).
When the paddle hit is detected, we raise the touched flag and increase the speed. The puck movement vector reflection is done in the paddle itself since it depends on the place on the paddle we hit.
rendering
The draw() procedure of Puck is pretty simple - we just have to draw a circle:
draw() {
lineWidth(2)
stroke(.55, .5, .5)
circle(this.x, this.y, this.r)
}
To setup the drawing, we set the line width in pixels and the stroke HSL color. Then we call the circle() function to draw the circle.
Paddle
This class represents the left and the right paddles.
Its draw() and evo(dt) functions are quite simple. The touch() method is the most complex one and handles the collision detection with the puck. It is also responsible for the puck movement vector reflection according to the REFLECT_VECTORS table.
defaults
First, we declare the df default object with Paddle width and height. Then we declare the REFLECT_VECTORS table - it contains the angles to the normal vector for each Paddle contact area.
// dna/Paddle.js
const df = {
w: 15,
h: 100,
}
const REFLECT_VECTORS = [
.25,
.20,
.15,
.10,
0,
0,
-.10,
-.15,
-.20,
-.25,
]
constructor
class Paddle {
constructor(st) {
augment(this, df, st) // set default and init values
this.actions = {} // a storage object for up and down actions
this.speed = ry(1) // speed = screen width in pixels
}
init() {
this.left = (this.name === 'left')
}
}
The constructor augments default and init values, creates a holder for actions and defines the speed.
The speed is defined as screen height in pixels/second.
Which means a paddle can travel from top to bottom in one second.
The init() function checks the name of the paddle
and raises the left flag if it is 'left'.
We can't place that in the constructor, since the object
might not be named yet during the construction. The init() is called by Collider.JAM after the node
is named and attached to the scene tree.
collisions
This is where the most of the math happening:
rect() {
return {
x1: this.x-this.w/2,
y1: this.y-this.h/2,
x2: this.x+this.w/2,
y2: this.y+this.h/2,
}
}
touch(puck) {
const { x1, y1, x2, y2 } = this.rect()
const x = this.left? x2 : x1
const d = lib.math.distanceToSegment(puck.x, puck.y, x, y1, x, y2)
if (d < puck.r) {
// calculate normal vector components
const nvec = lib.math.normalVector(x, y1, x, y2)
// normal vector is inverted for the left paddle
// | |
// |--> o <--|
// | |
const nx = this.left? -nvec[0] : nvec[0]
const ny = this.left? -nvec[1] : nvec[1]
// calculate relative vertical hit point
const dy = puck.y - this.y
// reflection angles are inverted for the left paddle
const dir = this.left? -1 : 1
let fi = atan2(ny, nx)
const zone = limit(floor((dy + 50)/10), 0, 9)
fi += dir * REFLECT_VECTORS[zone] * PI
puck.dx = cos(fi)
puck.dy = sin(fi)
lib.sfx(res.sfx.boing, .3)
return true
}
return false
}
The rect() is a utility function that calculates the top-left and the bottom-right coordinates.
The touch(puck) function accepts the puck and tries to detect collision.
The collision is determined simply by calculating the distance between the puck center and the active segment of the paddle (the one facing the game field). If the distance is less than the puck radius, we consider the collision test positive.
Once the collision is detected, we calculate the angle of the normal vector. Then we calculate the impact zone and use it to determine the angle of the reflection vector to the normal vector.
The reflection angle is used to set the new movement vector for the puck.
behavior
Here the paddle x coordinate gets dynamically adjusted. That way, the game continues to function properly even when the browser window size is changed.
The second part of the function takes care of the movement
if the corresponding action is triggered.
evo(dt) {
// adjust x coordinate
if (this.left) this.x = rx(.05)
else this.x = rx(.95)
// move according to pressed keys
if (this.actions.up) {
this.y -= this.speed * dt
if (this.y < this.h/2) this.y = this.h/2 // top edge
}
if (this.actions.down) {
this.y += this.speed * dt
if (this.y > ry(1)-this.h/2) this.y = ry(1)-this.h/2 // bottom edge
}
}
rendering
The draw() just fills a rectangle with HSL-specified color:
draw() {
save()
translate(this.x, this.y)
fill(.6, .35, .45)
rect(-this.w/2, -this.h/2, this.w, this.h)
restore()
}
We use translate() to get into the paddle coordinate system (with 0:0 at the paddle center). That is why we MUST save() the context and restore() it afterward.
movement control
The functions up() and down() are used by keyboard event traps to trigger the movement:
up(active) {
this.actions.up = active
}
down(active) {
this.actions.down = active
}
Control
Keys are traped by the following 2 functions in 2 files:
// trap/keyDown.js
function keyDown(e) {
switch(e.code) {
case 'Escape':
trap('newGame')
break
case 'KeyW': case 'KeyA': lab.left.up(true); break;
case 'KeyS': case 'KeyZ': lab.left.down(true); break;
case 'ArrowUp': case 'PageUp': lab.right.up(true); break;
case 'ArrowDown': case 'PageDown': lab.right.down(true); break;
}
}
// trap/keyUp.js
function keyUp(e) {
switch(e.code) {
case 'KeyW': case 'KeyA': lab.left.up(false); break;
case 'KeyS': case 'KeyZ': lab.left.down(false); break;
case 'ArrowUp': case 'PageUp': lab.right.up(false); break;
case 'ArrowDown': case 'PageDown': lab.right.down(false); break;
}
}
Here we access the paddles directly through the lab with lab.left and lab.right. We raise movement flags in keyDown() and reset them in keyUp().
Game Events
new game
The "new game" event is traped by:
// trap/newGame.js
function newGame() {
// reset the score
env.score = {
left: 0,
right: 0,
}
// move paddles to the center
lab.left.y = ry(.5)
lab.right.y = ry(.5)
// replace the puck
kill(lab.puck)
trap('spawnPuck')
// show the start timer
lab.score.countdown = 3
lab.puck.hold = 0 // puck shouldn't wait
}
Here we do the initial setup and object spawning. It is triggered by setup.js when the game starts and also fired manually by pressing the Escape key.
score
The following trap counts the score:
// trap/score.js
function score(player) {
env.score[player] ++
trap('spawnPuck')
lib.sfx(res.sfx.score, .8)
}
We use a global-level node env to keep the score object. The player argument can be left or right. And we rise the score accordingly.
spawn puck
spawnPuck creates a new Puck in /lab:
// trap/spawnPuck.js
function spawnPuck() {
lab.spawn('Puck', {
name: 'puck',
x: rx(.5),
y: ry(.5),
speed: ry(.8),
})
}
The puck is created by the spawn() function in lab. We pass a DNA name and an init object there.
The provided init object sets the name, the speed, and the puck's coordinates. With screen-relative functions rx() and ry() we place it in the middle of the screen.
Setup
setup.js contains the function to setup the game before it starts:
function setup() {
trap('newGame')
}
It just traps the new game event.
Source Code
Check out the full source on GitHub. It also contains the sound effect files used for countdown and collisions.
Clone it with git:
git@github.com:invider/pong-ce.mod.git
And then run it with Collider.JAM:
cd pong-ce.mod
jam play
Ideas for Improvement
There are multiple directions you can go from here:
- You can introduce more variety into the gameplay by providing some kind of random powerups to modify game properties - like increase paddle speed or size, slow down the puck, etc...
- You can improve visuals by adding bitmapped graphics or particle effects on collisions.
- You can implement simple AI to be able to play against the computer.
- You can add the ability to play over the network.
There might be other interesting ideas waiting to be implemented. Even the old and familiar gameplay from Pong can be rethought and refreshed.
Summary
Collider.JAM makes a good job of hiding complexity and boilerplate.
- You don't need any imports and exports.
- You don't bother yourself with a bunch of load() calls to obtain necessary resources.
- You don't extend any of the framework prototypes in order to place something on the screen - any plain JS objects will do the trick.
You just name the functions and place the files in appropriate folders according to Collider.JAM conventions. And everything is just magically mixed together.
That is the magic of Collider.JAM!
Also notice how straightforward drawing and other common operations like sin(), cos(), trap(), augment(), and kill(). They are just functions available from the global context. No need to access those features from the bunch of incomprehensive utility objects like Context and Math. They are just there under your tips when you need them.
It makes JavaScript almost as clear and straightforward as BASIC. The code looks like a polite conversation with the framework instead of a rude arguing with the object system.
Top comments (0)