Creating an Audio Powered 8 Puzzle in Vue.js for Evanescence
Featuring Vue Directives and Howler.js
I don’t have the best memory but I believe the last time I developed a puzzle to tease something for a client was 2011, when I built a puzzle for Manchester Orchestra’s Simple Math release. Well, I finally had the opportunity to stop my puzzle drought when I was approached a few weeks back to develop an audio puzzle for Evanescence and their upcoming single, “The Bitter Truth.” Their idea was to take a standard sliding 8 puzzle on a 3x3 grid and add audio to it somehow. I just knew this was going to be fun to tackle in Vue.js so I quickly put a proposal together, got approval, and we went to work.
We ended up developing an 8 puzzle which requires users to use audio cues instead of visual cues to decipher the correct arrangement of tiles. This adds an extra dimension of logic to an already tricky puzzle. If you’re ready for a challenge, head over to the puzzle and give it a shot. If you can’t figure it out, I hear some fans have uploaded a solution to YouTube. 😅
Read on to find out how it came together.
Building the 8 Puzzle in Vue
To begin with, we’ll need to develop the core 8 puzzle solution using Vue.js. I’ve included the CodePen.IO above as an example of how I’ve developed it for our campaign. Let’s break down some of the key areas.
Tile Layout
First, we just want an array of data which will represent our tiles and their solved arrangement. For this demo, I’m simply splitting a string of numbers 123456789
into an array ["1","2","3","4","5","6","7","8","9"]
using the string split
method. I’m also using the shuffle
method of Lodash to shuffle the tiles initially.
tiles: _.shuffle('123456789'.split('')),
solution: '123456789'.split('')
Now that we have our tile data, we can add them as divs to the view with a few additional properties. First, let’s give it a key
and a click event which calls the move
method. Then, we’ll give the 9 tile a class of empty as this will be the missing tile from our puzzle.
<div id="puzzle">
<div class="tile" v-for="(tile, i) in tiles" :key="tile" @click="move(i) :class="{ empty: tile === '9' }">
{{ tile }}
</div>
</div>
In order to lay these tiles out in a 3x3 grid, we can use the (you guessed it) CSS grid on our #puzzle
holding div. Here’s how I wrote it up.
#puzzle{
display: grid;
grid-gap: 1px;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
height: 240px;
width: 240px;
}
Then you can style the .tile
as you like. I kept it simple. Oh, but let’s not forget to hide the .empty
tile.
.tile.empty{
opacity: 0;
pointer-events: none;
}
Movement
Now, we can start working on the tile movement. Each position in the 3x3 grid can only make certain moves. For example, a tile in the top left corner could only move to the right or down. Rather than writing logic figure out movements dynamically, I took the easier path and simply defined each position’s possible movements as an array.
moves: [
[1, 3],
[0, 2, 4],
[1, 5],
[0, 4, 6],
[1, 3, 5, 7],
[2, 4, 8],
[3, 7],
[4, 6, 8],
[5, 7]
]
However, knowing the possible movements is only half of the problem. We also need to know if the the tile can make the movement. This is only true if the empty space is present in one of the possible moves. So, we’ll get an array of possible moves for the current tile and then check to see if the index of the empty tile is includes.
let emptyIndex = this.tiles.findIndex(tile => tile === '9')this.moves[i].includes(emptyIndex)
This all comes together in the move()
method which finally swaps the clicked tile with the empty tile if allowed.
move(i) {
let emptyIndex = this.tiles.findIndex(tile => tile === '9') if (this.moves[i].includes(emptyIndex)) {
let tile = this.tiles[i]
this.$set(this.tiles, i, this.tiles[emptyIndex])
this.$set(this.tiles, emptyIndex, tile)
}
}
Solution
With movements finally in place, we can compute and watch for a solution in Vue using the isEqual
function of Lodash.
computed: {
solved() {
return _.isEqual(this.tiles, this.solution)
}
},
watch: {
solved(newVal) {
if (newVal) {
alert('Solved.')
}
}
}
And that’s about the simplest 8 puzzle I can come up with in Vue. 😅
Adding Audio to Tile Events
With a simple 8 puzzle solution in place, it is now time to add audio to the experience. This was trickier to figure out than I thought. Initially, we thought about adding play buttons to the top corner of each tile but I explained that this would be visually confusing and potentially break the user experience, as we were already listening for clicks connected to tile movements. After a bit of thinking, I remembered an old Spotify iOS feature which allowed users to press and hold to preview audio. So, I thought maybe we could listen for long press events and clicks on the same tile element.
It didn’t take me long to find this excellent blog by Nosa Obaseki which not only covers a simple solution for long press events but also introduced me to custom Vue directives. A Vue directive allows us to add all sorts of dynamic functionality to an element. In our case, we wanted to know whether or not a long press or click occurred. It all begins by adding listeners which listen for mouse and touch events. Then, in abstract, when a user tap or clicks a tile, we start a timer to see if they keep their mouse or finger down for a certain period of time. If so, we count it as a long press. I invite you to check out Nosa’s blog but I will cover some of my solution, as it relates to the puzzle, here.
Setting up Vue Directive
To begin with, we need three variables. The pressTimer
variable keeps track of whether or not a timer is currently running. The pressDelay
allows us to set how many milliseconds need to elapse before a long press is registered. Finally, I noticed I also needed a pressed
variable to help me navigate between long press and click events.
let pressTimer = null
let pressDelay = 500
let pressed = false
We’ll then establish three functions: start()
, cancel()
, and click()
. Let’s start with start()
. When a user’s mouse is down or has started touching one of our tiles, we’ll start a timer using the setTimeout
function at the pressDelay
interval. If this timeout ends up firing, we should begin playing the audio clip associated with the tile. (More on playing audio later.)
let start = (e) => {
if (e.type === 'click') {
return
} if (pressTimer === null) {
pressTimer = setTimeout(() => {
pressed = true // start clip
}, pressDelay)
}
}el.addEventListener('mousedown', start)
el.addEventListener('touchstart', start)
Now, when a user’s mouse moves out of an element or their touch is canceled we should stop the clip and clear the timer.
let cancel = (e) => {
// stop clip if (pressTimer !== null) {
clearTimeout(pressTimer)
pressTimer = null
}
}el.addEventListener('mouseout', cancel)
el.addEventListener('touchcancel', cancel)
Finally, we’ll listen for click and touch end events. If this click event was not part of a long press event, we’ll move the tile. In either case, we make sure to call the cancel()
function to clear any logic.
let click = (e) => {
cancel() if (!pressed) {
// move tile
} else {
pressed = false
}
}el.addEventListener('click', click)
el.addEventListener('touchend', click)
Playing Clips with Howler
The last bit of functionality we need is to actually play the clips. Since our clips are sections of a single composition which have been rearranged, I like using the sprite functionality of Howler.js to power playback. Howler allows us to take a single audio file and declare the start point and duration of each clip. Here’s what initializing that audio object looks like.
this.clips = new Howl({
src: ['clips.mp3'],
sprite: {
1: [0, 6386],
2: [7355, 6395],
3: [14688, 6402],
4: [22000, 6438],
5: [29355, 4839],
6: [35125, 1698],
7: [37768, 4818],
8: [43510, 1698]
},
onload: () => {
resolve()
}
})this.clips.load()
Then, playing the clips simply requires the right method.
this.clips.play('1')
Thanks
As of Monday, over 2000 fans have solved the puzzle! That’s a great turnout and signs of an active and interested fanbase. Thanks to BMG, Revelation Management Group, and Evanescence for the challenge and letting me be a part of this. “Better Without You” releases Friday, March 5th.