DEV Community

loading...
Cover image for Make Pong with JavaScript & Collider.JAM

Make Pong with JavaScript & Collider.JAM

invader profile image Igor Khotin ・11 min read

Tools Needed:

  • NodeJS and NPM
  • any modern web browser
  • any text editor

Full Source: GitHub.
Framework: Collider.JAM, run npm 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
Enter fullscreen mode Exit fullscreen mode

And run the empty mod with:

jam -d
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
    }
Enter fullscreen mode Exit fullscreen mode

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,
]
Enter fullscreen mode Exit fullscreen mode

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')
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
Enter fullscreen mode Exit fullscreen mode

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()
    }
Enter fullscreen mode Exit fullscreen mode

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
    }
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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),
    })
}
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And then run it with Collider.JAM:

cd pong-ce.mod
jam play
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)

pic
Editor guide