Building a Wheel of Fortune JavaScript Game for Zoom Calls


In this article, I describe how I developed a JavaScript “Wheel of Fortune” game to make online meetings via Zoom a little more fun during the global pandemic.

The current pandemic has forced many social activities to go virtual. Our local Esperanto group, for example, now meets online (instead of in person) for our monthly language study meetups. And as the group’s organizer, I’ve had to to re-think many of our activities because of the coronavirus. Previously, I could add watching a film, or even a stroll through the park, to our mix of activities in an effort to avoid fatigue (constant grammar drills don’t encourage repeat attendance).

Our new Wheel of Fortune game was well received. Of course, SitePoint is a tech blog, so I’ll be presenting an overview of what went into building a rudimentary version of the game to screenshare in our online meetings. I’ll discuss some of the trade-offs I made along the way, as well as highlight some possibilities for improvement and things I should have done differently in hindsight.

First Things First

If you’re from the United States, you’re probably already familiar with Wheel of Fortune, as it’s the longest-running American game show in history. (Even if you’re not in the United States, you’re probably familiar with some variant of the show, as it’s been adapted and aired in over 40 international markets.) The game is essentially Hangman: contestants try to solve a hidden word or phrase by guessing its letters. Prize amounts for each correct letter is determined by spinning a large roulette-style wheel bearing dollar amounts — and the dreaded Bankrupt spots. A contestant spins the wheel, guesses a letter, and any instances of said letter in the puzzle are revealed. Correct guesses earn the contestant another chance to spin and guess, while incorrect guesses advance game play to the next contestant. The puzzle is solved when a contestant successfully guesses the word or phrase. The rules and various elements of the game have been tweaked over the years, and you can certainly adapt your own version to the needs of your players.

For me, the first order of business was to decide how we physically (virtually) would play the game. I only needed the game for one or two meetings, and I wasn’t willing to invest a lot of time building a full-fledged gaming platform, so building the app as a web page that I could load locally and screenshare with others was fine. I would emcee the activity and drive the gameplay with various keystrokes based on what the players wanted. I also decided to keep score using pencil and paper —something I’d later regret. But in the end, plain ol’ JavaScript, a little bit of canvas, and a handful of images and sound effect files was all I needed to build the game.

The Game Loop and Game State

Although I was envisioning this as a “quick and dirty” project rather than some brilliantly coded masterpiece following every known best practice, my first thought was still to start building a game loop. Generally speaking, gaming code is a state machine that maintains variables and such, representing the current state of the game with some extra code bolted on to handle user input, manage/update the state, and render the state with pretty graphics and sound effects. Code known as the game loop repeatedly executes, triggering the input checks, state updates, and rendering. If you’re going to build a game properly, you’ll most likely be following this pattern. But I soon realized I didn’t need constant state monitoring/updating/rendering, and so I forwent the game loop in favor of basic event handling.

In terms of maintaining state, the code needed to know the current puzzle, which letters have been guessed already, and which view to display (either the puzzle board or the spinning wheel). Those would be globally available to any callback logic. Any activities within the game would be triggered when handling a keypress.

Here’s what the core code started to look like:

(function (appId) {
  
  const canvas = document.getElementById(appId);
  const ctx = canvas.getContext('2d');

  
  let puzzles = [];
  let currentPuzzle = -1;
  let guessedLetters = [];
  let isSpinning = false;

  
  window.addEventListener('keypress', (evt) => {
    
  });
})('app');

The Game Board and Puzzles

Wheel of Fortune’s game board is essentially a grid, with each cell in one of three states:

  • empty: empty cells aren’t used in the puzzle (green)
  • blank: the cell represents a hidden letter in the puzzle (white)
  • visible: the cell reveals a letter in the puzzle

One approach to writing the game would be to use an array representing the game board, with each element as a cell in one of those states, and rendering that array could be accomplished several different ways. Here’s one example:

let puzzle = [...'########HELLO##WORLD########'];

const cols = 7;
const width = 30;
const height = 35;

puzzle.forEach((letter, index) => {
  
  let x = width * (index % cols);
  let y = height * Math.floor(index / cols);

  
  ctx.fillStyle = (letter === '#') ? 'green' : 'white';
  ctx.fillRect(x, y, width, height);

  
  ctx.strokeStyle = 'black';
  ctx.strokeRect(x, y, width, height);

  
  if (guessedLetters.includes(letter)) {
      ctx.fillStyle = 'black';
      ctx.fillText(letter, x + (width / 2), y + (height / 2));
  }
});

This approach iterates through each letter in a puzzle, calculating the starting coordinates, drawing a rectangle for the current cell based on the index and other details — such as the number of columns in a row and the width and height of each cell. It checks the character and colors the cell accordingly, assuming # is used to denote an empty cell and a letter denotes a blank. Guessed letters are then drawn on the cell to reveal them.

A potential game board rendered using the above code

Another approach would be to prepare a static image of the board for each puzzle beforehand, which would be drawn to the canvas. This approach can add a fair amount of effort to puzzle preparation, as you’ll need to create additional images, possibly determine the position of each letter to draw on the custom board, and encode all of that information into a data structure suitable for rendering. The trade-off would be better-looking graphics and perhaps better letter positioning.

This is what a puzzle might look like following this second approach:

let puzzle = {
  background: 'img/puzzle-01.png',
  letters: [
    {chr: 'H', x: 45,  y: 60},
    {chr: 'E', x: 75,  y: 60},
    {chr: 'L', x: 105, y: 60},
    {chr: 'L', x: 135, y: 60},
    {chr: 'O', x: 165, y: 60},
    {chr: 'W', x: 45,  y: 100},
    {chr: 'O', x: 75,  y: 100},
    {chr: 'R', x: 105, y: 100},
    {chr: 'L', x: 135, y: 100},
    {chr: 'D', x: 165, y: 100}
  ]
};

For the sake of efficiency, I’d recommend including another array to track matching letters. With only the guessedLetters array available, you’d need to scan the puzzle’s letters repeatedly for multiple matches. Instead, you can set up an array to track the solved letters and just copy the matching definitions to it when the player makes their guess, like so:

const solvedLetters = [];

puzzle.letters.forEach((letter) => {
  if (letter.chr === evt.key) {
    solvedLetters.push(letter);
  }
});

Rendering this puzzle then looks like this:


const imgPuzzle = new Image();
imgPuzzle.onload = function () {
  ctx.drawImage(this, 0, 0);
};
imgPuzzle.src = puzzle.background;


solvedLetters.forEach((letter) => {
  ctx.fillText(letter.chr, letter.x, letter.y);
});

A potential game board rendered using the alternative approach

For the record, I took the second approach when writing my game. But the important takeaway here is that there are often multiple solutions to the same problem. Each solution comes with its own pros and cons, and deciding on a particular solution will inevitably affect the design of your program.

Spinning the Wheel

At first blush, spinning the wheel appeared to be challenging: render a circle of colored segments with prize amounts, animate it spinning, and stop the animation on a random prize amount. But a little bit of creative thinking made this the easiest task in the entire project.

Regardless of your approach to encoding puzzles and rendering the game board, the wheel is probably something you’ll want to use a graphic for. It’s much easier to rotate an image than draw (and animate) a segmented circle with text; using an image does away with most of the complexity up front. Then, spinning the wheel becomes a matter of calculating a random number greater than 360 and repeatedly rotating the image that many degrees:

const maxPos = 360 + Math.floor(Math.random() * 360);
for (let i = 1; i < maxPos; i++) {
  setTimeout(() => {
    ctx.save();
    ctx.translate(640, 640);
    ctx.rotate(i * 0.01745); 
    ctx.translate(-640, -640);
    ctx.drawImage(imgWheel, 0, 0);
    ctx.restore();
  }, i * 10);
}

I created a crude animation effect by using setTimeout to schedule rotations, with each rotation scheduled further and further into the future. In the code above, the first 1 degree rotation is scheduled to be rendered after 10 milliseconds, the second is rendered after 20 milliseconds, etc. The net effect is a rotating wheel at approximately one rotation every 360 milliseconds. And ensuring the initial random number is greater than 360 guarantees I animate at least one full rotation.

A brief note worth mentioning is that you should feel free to play around with the “magic values” provided to set/reset the center point around which the canvas is rotated. Depending on the size of your image, and whether you want the the entire image or just the top portion of the wheel to be visible, the exact midpoint may not produce what you have in mind. It’s okay to tweak the values until you achieve a satisfactory result. The same goes for the timeout multiplier, which you can modify to change the animation speed of the rotation.

Going Bankrupt

I think we all experience a bit of schadenfreude when a player’s spin lands on Bankrupt. It’s fun to watch a greedy contestant spin the wheel to rack up a few more letters when it’s obvious they already know the puzzle’s solution — only to lose it all. And there’s the fun bankruptcy sound effect, too! No game of Wheel of Fortune would be complete without it.

For this, I used the Audio object, which gives us the ability to play sounds in JavaScript:

function playSound(sfx) {
  sfx.currentTime = 0;
  sfx.play();
}

const sfxBankrupt = new Audio('sfx/bankrupt.mp3');


playSound(sfxBankrupt);

But what triggers the sound effect?

One solution would be to press a button to trigger the effect, since I’d be controlling the gameplay already, but it was more desirable for the game to automatically play the sound. Since Bankrupt wedges are the only black wedges on the wheel, it’s possible to know whether the wheel stops on Bankrupt simply by looking at the pixel color:

const maxPos = 360 + Math.floor(Math.random() * 360);
for (let i = 1; i < maxPos; i++) {
  setTimeout(() => {
    ctx.save();
    ctx.translate(640, 640);
    ctx.rotate(i * 0.01745); 
    ctx.translate(-640, -640);
    ctx.drawImage(imgWheel, 0, 0);
    ctx.restore();

    if (i === maxPos - 1) {
      
      const color = ctx.getImageData(640, 12, 1, 1).data;
      if (color[0] === 0 && color[1] === 0 && color[2] === 0) {
        playSound(sfxBankrupt);
      }
    }
  }, i * 10);
}

I only focused on bankruptcies in my code, but this approach could be expanded to determine prize amounts as well. Although multiple amounts share the same wedge color — for example $600, $700, and $800 all appear on red wedges — you could use slightly different shades to differentiate the amounts: rgb(255, 50, 50), rgb(255, 51, 50), and rgb(255, 50, 51) are indistinguishable to human eyes but are easily identified by the application. In hindsight, this is something I should have pursued further. I found it mentally taxing to manually keep score while pressing keys and running the game, and the extra effort to automate score keeping would definitely have been worth it.

The differences between these shades of red are indistinguishable to the human eye

Summary

If you’re curious, you can find my code on GitHub. It isn’t the epitome and best practices, and there’s lots of bugs (just like a lot of real-world code running in production environments!) but it served its purpose. But ultimately the goal of this article was to inspire you and invite you to think critically about your own trade-off choices.

If you were building a similar game, what trade-offs would you make? What features would you deem critical? Perhaps you’d want proper animations, score keeping, or perhaps you’d even use web sockets so contestants could play together in their own browsers rather than via screensharing the emcee’s screen.

Looking beyond this particular example, what choices are you faced with in your daily work? How do you balance business priorities, proper coding practices, and tech debt? When does the desire to make things perfect become an obstacle to shipping a product? Let me know on Twitter.





Source link