Light Cycles

Light Cycles title and lightcycle

For my first Ironhack project I built a snake-type browser game (hosted on GitHub Pages) using JavaScript, CSS and HTML5, including the canvas element. Working with this "vanilla" tech stack helped me to better understand various fundamentals like event listeners, classes (from ES6 aka ECMAScript 2015), callbacks, clean code and more. I'm glad to have worked on this foundation before moving on to using a library like React.

Inspired by the 1982 film Tron, the purpose of the game is to "derez" (destroy) the enemy "Light Cycle." Light Cycles move in straight lines and leave behind solid "jetwalls" in their wake. Players, represented by Light Cycles, race around a two-dimensional "Game Grid," using keys (up, down, left, right) to change direction. Players can also use fusion power to speed up their Light Cycle as well as shoot bullets.

The game is over when a player crashes into either the other player's or their own jetwall or bullet explosion.

Light Cycle gameplay, animated

Given that this was a learning project, I'm proud that the game is actually fun. I realized it wouldn't be interesting if players couldn't reach one another, so I added elements like acceleration and bullets (which can blow up walls) to improve the gameplay. Below find some links and highlights.

Links

MVC design pattern

MVC design pattern

I followed a model-view-controller design pattern, with most of the JavaScript divided across three files:

  • Lightcycle.js – the model where the Lightcycle class is defined, with a constructor that initializes the starting position, speed and other player configurations, and methods that allow the cycle to move forward, turn, speed up, burn fuel, stop, etc. Bullets are handled by a separate class since once a bullet is shot, it's an independent entity in the game. Finally, there's a class for the Fuel which appears randomly on the board and regenerates when picked up or shot. State is stored in arrays (e.g. row and column positions).
  • Main.js – the view where the canvas and audio elements are set up, with a start button and event listener and game over messages. Clicking start will instantiate a new (singleton) instance of the Game class, defined in the controller.
  • Game.js – a stateless controller where the Game class is defined, which in turns creates two Lightcycle instances and a Fuel instance. An update loop driven by requestAnimationFrame() cycles through methods that draw the jetwalls, check for crashes, generate fuel, check for fuel pickup, draw bullet paths, etc. Finally, event listeners set up here listen for keydown and keyup events.

I left the canvas paint instructions in the controller (rather than defining them in the view) since the logic is so simple, i.e. painting a jetwall requires one line of code to fill a rectangle:

_drawJetwall() {
    this.ctx.fillStyle = player1Config.color;
    const cycle1 = this.player1.jetwall[0];
    this.ctx.fillRect(cycle1.column * this.cellWidth, cycle1.row * this.cellWidth, this.cellWidth, this.cellWidth);

    this.ctx.fillStyle = player2Config.color;
    const cycle2 = this.player2.jetwall[0];
    this.ctx.fillRect(cycle2.column * this.cellWidth, cycle2.row * this.cellWidth, this.cellWidth, this.cellWidth);
}

However, if the UI logic were more involved (e.g. involving complex graphics), these should be defined in Main (view) and passed to the controller as a callback to be called at the appropriate moment.

Update cycle

The controller loops through the following update cycle every animation frame:

_update() {
    let gameOver = false;
    this.updateScore();
    if (this.bullets.length > 0) {
        this.drawBullets();
        this.eraseBulletTrails();
        this.checkBulletStrike();
    }
    this.checkFuelPickup();
    this._drawJetwall();
    this._checkCrash();
    if (this.player1.crashed || this.player2.crashed) {
        this._endingSequence();
        gameOver = true;
    }
    if (!!this.interval && !gameOver) {
        this.interval = window.requestAnimationFrame(this._update.bind(this));
    }
}

Creating the logic for the various types of game collisions was among the most involved parts of the project. For instance, players can crash into their own jetwall or that of the other player:

_hasCrashedOwnJetwall(cycle) {
    let crashed = false;
    cycle.jetwall.forEach((position, index) => {
        if (index > 3) {       // impossible to crash own jetwall until 4th step (after 3 turns)
            if (position.row === cycle.jetwall[0].row &&
                position.column === cycle.jetwall[0].column) {
                crashed = true;
            }
        }
    });
    return crashed;
}

_hasCrashedOtherJetwall(cycle1, cycle2) {
    let crashed = false;
    cycle2.jetwall.forEach(position => {
        if (position.row === cycle1.jetwall[0].row &&
            position.column === cycle1.jetwall[0].column) {
            crashed = true;
        }
    });
    return crashed;
}

_checkCrash() {
    if (this._hasCrashedOwnJetwall(this.player1)) {
        this.player1.crashed = true;
    }
    if (this._hasCrashedOtherJetwall(this.player1, this.player2)) {
        this.player1.crashed = true;
    }
    if (this._hasCrashedOwnJetwall(this.player2)) {
        this.player2.crashed = true;
    }
    if (this._hasCrashedOtherJetwall(this.player2, this.player1)) {
        this.player2.crashed = true;
    }
}

Or for example, when bullets hit the jetwall or fuel they create a small explosion that can derez a player (if it hits the light cycle), destroy fuel or destroy a part of a jetwall (by splicing out the piece of the array that stores the "tail"). There can be many bullets flying around at any given time so this check must be performed per bullet per frame.

checkBulletStrike() {
    this.bullets.forEach(bullet => {
        if (this._hasBulletHitSomething(bullet)) {
            bullet.hitSomething = true;
            bullet.stop();
            this.drawBlast(bullet);
            this.inflictBlastDamage(bullet);
            this.bullets.splice(this.bullets.indexOf(bullet), 1);
        }
    });
    if (this.fuel.destroyed) {
        this.fuel.destroyed = false;
    }
}

Even with a simple game, with multiple moving objects the logic of potential intersections can quickly become complex, which gives me a better appreciation for how amazing even the Nintendo games I played as a kid were.

Tron logo

Design & Audio

To fit with Tron's '80s aesthetic, I went with a neon on black color scheme, using the TR2N typeface for the logo and controls – throw an outer glow effect on there in Photoshop and boom. The futuristic light cycle art is from Tron Legacy (2010).

For sound, I used the song The Grid by Daft Punk from the Tron Legacy soundtrack. Beyond using a simple HTML5 audio element, the game gave me a chance to play around with the Web Audio API, with the following easily adapted to play multiple songs:

function startAudio() {
    document.AudioContext = document.AudioContext || document.webkitAudioContext;
    if (!audioCtx) {
        audioCtx = new AudioContext();
    }
    console.log('Audio context started');

    bufferLoader = new BufferLoader(
        audioCtx,
        [
            "audio/the-grid-32kbps.mp3", // Daft Punk - The Grid (Joseph Darwed Orchestral Rework) [Tron Soundtrack] [HD 1080p]
        ],
        playMusic
        );
    bufferLoader.load();
}

function playMusic(bufferList) {
    if (music) {
        stopMusic();
    }
    music = audioCtx.createBufferSource();
    music.buffer = bufferList[0];
    console.log(music);
    music.connect(audioCtx.destination);
    music.loop = true;
    music.start(0);
}

function stopMusic() {
    music.stop(0);
}
© 2021 — Designed & developed by Marguerite Roth