Look I'm going to level with you here... I really hate sliding puzzles. When I was a kid I remember getting one of these from a Cracker Barrel:

The Fifteen Puzzle
The Fifteen Puzzle

It was made of metal, which was admittedly pretty dope. I remember playing with it for hours. I kept it at my grandparent's house, so that might explain why I was so into it... it's definitely a "grandparent's house" kind of toy.

But as I grew up, I started to find sliding puzzles a bit stressful. You might get a few tiles to line up, but to complete the puzzle you'll often have to sacrifice your hard work and restart again and again. What a nightmare.

You know what I dont' hate though? That's right, you guessed it: CSS Grid and the Canvas API. You won't find THOSE bad boys at your grandparent's house. 🤠

Check it out:

Cool cool. You can upload your own photos from your computer, nice. And the slider allows you to change the number of tiles on screen. Anything higher than 4x4 is pretty ridiculous but hey fella, go for it. If you can manage to complete it, you'll get a dazzling rainbow effect. Congrats, you earned it.

How does it work?

There's a good chunk of code here, but it's not so bad.

We set up an Image object with an onload handler that calls a render function:

const renderDefaultImage = () => {
  image.crossOrigin = "";
  image.onload = render;
  image.src = DEFAULT_IMAGE_URL;
};

The image source is set to a constant called DEFAULT_IMAGE_URL. When this image is loaded, it triggers the onload function which calls our render function.

We set image.crossOrigin to "" before loading the image. This allows cross origin requests, i.e., requests for resources that are located on a different server. This lets us fetch an image from an image host. Without doing this, we may get a security error when attempting to set the images for our tiles.

Setting up our grid, or, the nitty griddy

So what does that render function look like?

const render = () => {
  const puzzleWidthTileCount = puzzleWidthTileCountEl.value;

  renderGrid(this, puzzleWidthTileCount);

  const squares = getSquaresArray(puzzleWidthTileCount, image);
  const randomizedSquares = getRandomizedSquareList(squares);

  renderSquares(randomizedSquares, puzzleWidthTileCount);
};

Not so bad! We determine the puzzle size by getting the value of the size slider, then we use that number to render our grid:

const renderGrid = (image, puzzleWidthTileCount) => {
  cleanGrid();
  setGridDimensions(image);
  setGridTemplate(puzzleWidthTileCount);
};

We clean the grid up first – this function does nothing but remove all of the grid's child nodes (the existing puzzle squares). Then we need to size the grid. There's a little logic here to deal with large images:

const setGridDimensions = (image) => {
  let width = image.width;
  let height = image.height;

  if (width > MAX_PUZZLE_WIDTH_PX) {
    const scaleFactor = MAX_PUZZLE_WIDTH_PX / image.width;

    width = MAX_PUZZLE_WIDTH_PX;
    height *= scaleFactor;
  }

  setWidthAndHeight(gridEl, width, height);
  setWidthAndHeight(completedImageEl, width, height);
};

So, if our image width is larger than the max defined in our constant, we set the width to the max and scale down our height by the same proportion to maintain the image's aspect ratio. You can try this by loading a big picture into the puzzle – it will be scaled down to 500px.

We also need to set up our grid template. Easy enough!

const setGridTemplate = (puzzleWidthTileCount) => {
  const gridTemplate = `repeat(${puzzleWidthTileCount}, 1fr)`;
  gridEl.style.gridTemplateColumns = gridTemplate;
  gridEl.style.gridTemplateRows = gridTemplate;
};

So with a puzzle of size 4, we'll have a grid with a column and row template of repeat(4, 1fr), with size 5 we'll have repeat(5, 1fr), you get the idea. By using repeat with fr or "fractional units", we create a grid that is always split equally into 4,5,6 etc. pieces.

Slicing up our image

Now what about rendering the squares? How do we chop our picture up into little pieces? The rest of the render function is where this happens:

const render = (image) => {
  ...
  
  const squares = getSquaresArray(puzzleWidthTileCount, image);
  const randomizedSquares = getRandomizedSquareList(squares);

  renderSquares(randomizedSquares, puzzleWidthTileCount);
};

Well, let's look at getSquaresArray() since this is where the heaviest lifting happens.

const getSquaresArray = (puzzleWidthTileCount, image) => {
  const squares = [createHoleEl()];
  const totalPuzzleWidthTileCount = puzzleWidthTileCount * puzzleWidthTileCount;

  const squareWidth = image.width / puzzleWidthTileCount;
  const squareHeight = image.height / puzzleWidthTileCount;

  setCanvasSize(squareWidth, squareHeight);

  ...
};

First, we create our array of squares. It starts out with nothing more than a "hole" square – this just represents the actual hole in the puzzle. We calculate the size of each tile image by dividing our image size into equal parts, and then we set our canvas element size to this tile size (I didn't include the code of this function here, but it's literally just setting the width and height properties of the canvas element). We use this canvas to draw all of our tiles. If we don't set the size properly, then we will render tile images with a bunch of extra transparent space, and that's no good!

Now how about the rest of our square creation function?

for (let i = 1; i < totalPuzzleWidthTileCount; i++) {
    const squareEl = createSquare();
    const column = (i % puzzleWidthTileCount) + 1;
    const row = Math.floor(i / puzzleWidthTileCount) + 1;

    setOriginalColumnAndRowAttr(squareEl, column, row);
    squareEl.style.backgroundImage = getSquareBackgroundImageURL(puzzleWidthTileCount, i, squareWidth, squareHeight);

    squares.push(squareEl);
  }

We calculated the total tile count by squaring the puzzle size – now we're looping through and creating our tiles. Note that we start at 1, not zero, because we already created our hole element, so that's one square already completed.

The createSquare() function is fairly boring. It does nothing but create a div for each square and attach a bunch of event listeners.

We calculate the current row and column with some simple modulus and division (note that we add 1 because the CSS grid is 1 based, not zero based! This means that row "zero" does not exist). We then store these values as attributes on our square elements. This gives us a nice and simple way of checking for completion – we can just check if the square's position matches this stored attribute, which we'll see later.

The most important part is our getSquareBackgroundImageURL() function:

const getSquareBackgroundImageURL = (puzzleWidthTileCount, index, squareWidth, squareHeight) => {
  const cropX = (index % puzzleWidthTileCount) * squareWidth;
  const cropY = Math.floor(index / puzzleWidthTileCount) * squareHeight;

  context.drawImage(
    image,
    cropX,
    cropY,
    squareWidth,
    squareHeight,
    0,
    0,
    squareWidth + 1,
    squareHeight + 1
  );

  return `url(${canvasEl.toDataURL(fileType)})`;
};

Cool, so we first calculate the X and Y coordinates that we need to crop from our source image. This is not so bad, we just march across our image, slicing off little squares. Then we use our canvas' context (which we stored globally at the top of our file using  canvasEl.getContext("2d");) to draw a cropped portion of our image. We use the most specific overload of drawImage which gives us the ability to crop.

What we return is a string containing the CSS URL function. The toDataUrl() function gives us our image data encoded as the image format specified (we use fileType here, a global which is set to the format of whatever image we upload). We can set the background image of our squares using this URL. Sick!

Shufflin' and showin'

So we now have a bunch of div elements representing our puzzle tiles with little portions of our source image on them. We just have to shuffle this list so that it's all jumbled up:

const getRandomizedSquareList = (squares) => {
  const randomizedSquares = [];

  for (let i = 0; i < squares.length; i++) {
    let randomSquare = getRandomElement(squares)

    while (randomizedSquares.includes(randomSquare)) {
      randomSquare = getRandomElement(squares);
    }

    randomizedSquares.push(randomSquare);
  }

  return randomizedSquares;
};

Sweet. Now we're all jumbled up. Let's throw those squares on the grid:

const renderSquares = (squares, puzzleWidthTileCount) => {
  for (let i = 0; i < squares.length; i++) {
    const squareEl = squares[i];
    gridEl.appendChild(squareEl);

    squareEl.setAttribute(
      "data-current-row",
      Math.floor(i / puzzleWidthTileCount) + 1
    );
    squareEl.setAttribute(
      "data-current-column",
      (i % puzzleWidthTileCount) + 1
    );

    const animationDelay = getBalancedAnimationDelay(i, puzzleWidthTileCount);
    animateSquareEntrance(squareEl, animationDelay);
  }
};

After appending each item on the grid, we set another attribute on it – its current row and column. We'll keep track of this as we move squares so that we can check for a win state. There's a cool little animation that I added to the squares when they are rendered, but we'll ignore that for the sake of time here.

Movin'

To move the tiles around, we just need to check if the tile is next to the hole in the puzzle. If it is, we can move it:

const handleSquareClick = (event) => {
  event.preventDefault();

  const squareBeingMoved = event.target;

  if (isNextToHole(squareBeingMoved)) {
    moveSquare(squareBeingMoved);
  }
};

Checking if the square is next to the hole is easy enough. We can just check their current row and column attributes:

const isInNearbyRow =
    squareRow === holeRow &&
    (squareColumn === holeColumn - 1 || squareColumn === holeColumn + 1);

  const isInNearbyColumn =
    squareColumn === holeColumn &&
    (squareRow === holeRow - 1 || squareRow === holeRow + 1);

I've left out some clerical work you have to do to get the attribute values, but that's all straightforward enough. Once we have those values we just check that either:

  • The row matches the hole's row and the column is only one off, or
  • the column matches the hole's column and the row is only one off

Then, we simply swap the row/column attributes and grid positions of the hole and the tile being moved:

const updateGridPosition = (el, newRow, newColumn) => {
  el.setAttribute("data-current-row", newRow);
  el.setAttribute("data-current-column", newColumn);

  el.style.gridArea = `${newRow} / ${newColumn} / ${parseInt(newRow) + 1} / ${
    parseInt(newColumn) + 1
  }`;
};

But I can hear you saying "wait Zach! Zach! Hello? Zach... are you there? Hey! How do we animate this movement?! Hello? Are you even listening?".

First of all, I'm sorry I couldn't hear you – I was listening to this at full volume again and got lost in it.

To animate the squares, we use the FLIP technique of course! FLIP stands for "first, last, invert, play". The idea is that to animate things smoothly, you can take note of their first position and their new updated position, then transition this.

In this case, we know how large each grid square is. So we just need to know the direction we're animating (we could also use some other APIs to get the first position, but since we know where the square came from, this approach is more efficient):

const animateMovement = (squareEl, direction, height, width) => {
  let keyframes;

  if (direction === "up") {
    keyframes = [
      { transform: `translateY(${height}px)` },
      { transform: "translateY(0)" },
    ];
  } else if (direction === "down") {
    keyframes = [
      { transform: `translateY(-${height}px)` },
      { transform: "translateY(0)" },
    ];
  } else if (direction === "right") {
    keyframes = [
      { transform: `translateX(-${width}px)` },
      { transform: "translateX(0)" },
    ];
  } else if (direction === "left") {
    keyframes = [
      { transform: `translateX(${width}px)` },
      { transform: "translateX(0)" },
    ];
  } else {
    throw "Unknown direction passed to animateMovement";
  }

  squareEl.animate(keyframes, {
    easing: "ease",
    duration: 400,
    composite: "replace",
  });

So if the square has moved up a column, we know that it was exactly a tile's length beneath where it currently is. We then animate it moving from the row beneath it to its current position.

Let's say each tile in this case is 100x100 px.

First: 100 pixels beneath its current grid position

Last: Its current grid position

So to invert this and play it, we start at 100 pixels beneath the tile's current grid position and animate to its current grid position.

This gif might help you visualize it:

The FLIP technique applied to the puzzle grid

Did that actually help?? I hope it did! I slowed and looped the animation here so it was a little easier to see.

Winnin'

Checking for completion of the puzzle is pretty easy:

const isCompleted = () => {
  const squareEls = getAllSquareEls();

  return Array.from(squareEls).every((squareEl) => {
    const squareOriginalRow = squareEl.getAttribute("data-original-row");
    const squareOriginalColumn = squareEl.getAttribute("data-original-column");
    const squareCurrentColumn = squareEl.getAttribute("data-current-column");
    const squareCurrentRow = squareEl.getAttribute("data-current-row");

    return (
      squareOriginalRow === squareCurrentRow &&
      squareOriginalColumn === squareCurrentColumn
    );
  });
};

All we have to do is check whether the "original" and "current" row/column values match for each square. If so, you've won! 🕺

Custom pictures

To allow for uploading custom pictures, we just need some logic on our file input:

function handleUpload() {
  const file = this.files[0];
  fileType = file.type;

  const newFileURL = URL.createObjectURL(file);

  URL.revokeObjectURL(image.src);
  image.src = newFileURL;
  completedEl.src = newFileURL;

  unsetComplete();
}

const prepareFileUpload = () => {
  const inputEl = document.getElementById("upload");
  inputEl.addEventListener("change", handleUpload, false);
};

We read the file and get its type, then we just create a URL for the image data. When we set our image's source to this url, it triggers the render event that we set up for it above. We also need to set the source of our completed image, which is just a separate image we overlay on top of the puzzle when you when to give the illusion that the puzzle has combined into a completed image. In reality they're two separate elements. 🤫

Phew.

Obviously, that's not all of the code, but it's a decent overview. I learned a few things doing this:

  • Image manipulation with the Canvas API is shockingly easy
  • Local file uploading is also very easy
  • The animation API is awesome for one-off transition animations

But surely there's more?

While working on this, I had some ideas on how to make the sliding puzzle more interesting as a game. Stay tuned for an update coming later!

Check out the full source code.