The other day, a recollection popped into my mind of a small puzzle toy from my childhood, a slider puzzle where 15 square tiles are placed in a frame in a 4 x 4 arrangement of cells leaving one free space. A set of ridges and grooves on the edges of each tile and the frame allows the tiles to slide past one another whilst holding the tiles in the frame. At any given time any tile adjacent to the free space can move into that space, and otherwise the tiles are prevented from moving. Moving a tile into the free space then leaves a new free space where the tile came from and another tile can then move into that new space. The idea is by repeated sliding of the tiles in this way to arrange the tiles into some predetermined order.
Apparently this is called a "15 Puzzle" and has been around since the 1870s. Searching the web returns a number of recreations written in a variety of programming languages and indeed there are several articles here on dev.to including https://dev.to/artydev/let-us-code-a-sliding-puzzle-9n, https://dev.to/xzanderzone/making-a-slider-puzzle-in-java-script-83m, and https://dev.to/claurcia/slide-puzzle-5c55 all in JavaScript and https://dev.to/mfbmina/building-a-sliding-puzzle-with-go-3bnj in Go. It's also presented as a good starter challenge for those learning JavaScript.
What piqued my interest though was the idea that it ought to be recreatable on the web using no programming language at all! That is, an implementation using just pure HTML and CSS. So I present it below. The one compromise I had to make was that the 10 games provided have fixed pre-shuffled starting positions.
For this, the predetermined order is to show a completed picture.
The basic principle of this implementation is that each tile retains a state record of where it is within the frame. There aren't many ways to change and hold state in HTML and CSS, but the most common is the "checkbox hack" and this implementation makes heavy use of it. For anyone unfamiliar with the checkbox hack, when a <label>
element is clicked or tapped the activation behaviour of its associated form control, if it has one, is fired. When the associated control is a checkbox, the checkbox state is toggled, unchecked to checked and checked to unchecked. Similiarly, when the associated control is a radio button, that radio button is checked, and any other radio button in the same group that was checked is unchecked. Then a CSS selector can measure the checkedness of the control via the :checked
pseudo-class and apply different CSS to other elements depending on that state.
So each tile has a pair of radio button groups each with four radio buttons. One of these groups retains the tile's position in the X-axis and the other its position in the Y-axis. (Or horizontal position and vertical position respectively, if you prefer.) The fifteen tiles are each initially given a different combination of X and Y coordinates via their radio buttons so that each one occupies a different cell in the frame.
The tiles are initially placed in the frame's top, left cell and are then moved within the frame via CSS measuring the state of the radio buttons by applying a translation transform to them:
/* "X" refers to the X-axis cell positions, "Y" to the Y-axis cell positions.
* 0, 1, 2, 3 refers to the position on that axis,
* 0 = left and top, 3 = right and bottom respectively.
*/
.tile:has(.X0:checked~.Y0:checked) {
transform: translate(0%, 0%);
}
.tile:has(.X0:checked~.Y1:checked) {
transform: translate(0%, 100%);
}
.tile:has(.X0:checked~.Y2:checked) {
transform: translate(0%, 200%);
}
.tile:has(.X0:checked~.Y3:checked) {
transform: translate(0%, 300%);
}
.tile:has(.X1:checked~.Y0:checked) {
transform: translate(100%, 0%);
}
/* and so on for the remainder of the sixteen combinations */
The tile then also contains eight label elements, corresponding to the eight radio buttons. Each label is absolute positioned overlaying one another and each completely fills the tile. The labels are transparent, and are initially set up to not respond to clicks and taps by setting pointer-events:none
on all of them.
The next step is for the CSS selectors to identify where the empty cell is. This is done by elimination, it's the cell for whose X,Y coordinates are not represented by the radio button group pair of any of the fifteen tiles.
For example, if this matches:
.frame:not(:has(.tile .X0:checked~.Y0:checked)) { .... }
then the empty cell must currently be in the top left hand corner cell. Repeat this for each of the sixteen cells and exactly one of them will match.
Once that is done, the cells of adjacent to the empty cell can be identified. If the empty cell is in a corner, then there are exactly two tiles that can move into that cell, otherwise, if the empty cell is against one of the frame's sides, there are three tiles that can move into the cell, otherwise the empty cell must be one of the four middle cells, and there are four tiles that can move to it. For each of those tiles, exactly one of the tiles' eight labels will activate the correct radio button necessary to move the tile to the empty cell. That label is enabled by setting itspointer-events
value back to auto
. So as examples:
/* Top, left corner */
.frame:not(:has(.tile .X0:checked ~ .Y0:checked)) {
:is(
.tile:has(.X0:checked ~ .Y1:checked) label.Y0,
.tile:has(.X1:checked ~ .Y0:checked) label.X0
) {
pointer-events: auto;
}
}
/* right most cell of row two */
.frame:not(:has(.tile .X1:checked ~ .Y3:checked)) {
:is(
.tile:has(.X1:checked ~ .Y2:checked) label.Y3,
.tile:has(.X0:checked ~ .Y3:checked) label.X1,
.tile:has(.X2:checked ~ .Y3:checked) label.X1
) {
pointer-events: auto;
}
}
/* second cell from left on row three */
.frame:not(:has(.tile .X2:checked ~ .Y1:checked)) {
:is(
.tile:has(.X2:checked ~ .Y0:checked) label.Y1,
.tile:has(.X2:checked ~ .Y2:checked) label.Y1,
.tile:has(.X1:checked ~ .Y1:checked) label.X2,
.tile:has(.X3:checked ~ .Y1:checked) label.X2
) {
pointer-events: auto;
}
}
The last step of the game is to identify when the puzzle is solved. This is simply a case of checking that the 15 tiles all have their expected X and Y axis radio buttons set to their "solved" position.
/* Each tile is assigned a letter "a" to "o".
* The puzzle is solved when the tiles are in alphabetical order
* reading left to right and top to bottom
*/
.frame:has(.a .X0:checked ~ .Y0:checked):has(.b .X1:checked ~ .Y0:checked):has(
.c .X2:checked ~ .Y0:checked
):has(.d .X3:checked ~ .Y0:checked):has(.e .X0:checked ~ .Y1:checked):has(
.f .X1:checked ~ .Y1:checked
):has(.g .X2:checked ~ .Y1:checked):has(.h .X3:checked ~ .Y1:checked):has(
.i .X0:checked ~ .Y2:checked
):has(.j .X1:checked ~ .Y2:checked):has(.k .X2:checked ~ .Y2:checked):has(
.l .X3:checked ~ .Y2:checked
):has(.m .X0:checked ~ .Y3:checked):has(.n .X1:checked ~ .Y3:checked):has(
.o .X2:checked ~ .Y3:checked
)
~ .options
.success {
display: block;
}
The rest is cosmetic. The sliding is done with a simple transition of the transform described above
.tile {
transition: 0.5s transform;
@media (prefers-reduced-motion) {
transition: none;
}
}
and each tile shows a portion of the game's image using background-size
and background-position
.tile {
background-size: 400%;
}
#board1 .tile {
background-image: url("https://alohci.net/image/dev.to/slidergame/mullermarc-k7bQqdUf954-unsplash.webp");
}
.a {
background-position: 0% 0%;
}
.b {
background-position: 33.333% 0%;
}
.c {
background-position: 66.667% 0%;
}
.d {
background-position: 100% 0%;
}
.e {
background-position: 0% 33.333%;
}
/* and so on for the remaining tiles */
and there's a single set of radio buttons to choose which of the ten games to play.
To play the game, simply click or tap on the tile you want to slide to the empty cell.
I've also provided a "barebones" mode to show the tile letters and radio buttons which might help with the understanding of how the HTML and CSS works.
So here's the completed puzzle game. Please let me know any feedback you have.
Top comments (1)
I had this idea in draft since a while. I think I can make it "done" now 😆
The trick of the radios is great. I never thought about using them before.