A CSS crime originally made for Cohost: the board game Rush Hour, implemented using only CSS. This version has been only slightly modified from the one on Cohost:
- It uses classes and some CSS variables, instead of gobs of inline CSS generated from a Python script, as Cohost required.
- The positioning is slightly simpler, which makes the implementation easier to explain.
- I’ve touched up the aesthetics very slightly, and made the arrows a bit bigger in the hopes it’s easier to play on a small touchscreen.
- The extra puzzles (below) use a new set of colors. The starting colors are as they appeared on Cohost.
The goal is to free the red car. Pretend that they’re cars.
This starting arrangement was borrowed from the final card in the original Rush Hour, #40, which turns out to be the hardest possible game — in the sense that it requires the most moves to solve.
If you’d like to play some others, here’s a selection of the “hardest” puzzles from Michael Fogleman’s database of every nontrivial Rush Hour, in ascending order by difficulty. (He does also have his own Rush Hour player, with the more natural dragging controls you might expect.)
The original puzzle above is number 34. Many of these use wall tiles, not part of the board game, which is how they manage to be even more difficult.
How it works
Each piece is built from markup like this:
1<div class="-piece">
2 <details> <summary><div class="-back"></div><div class="-forth"></div></summary> <div class="-space"></div> </details>
3 <details> <summary><div class="-back"></div><div class="-forth"></div></summary> <div class="-space"></div> </details>
4 <details> <summary><div class="-back"></div><div class="-forth"></div></summary> <div class="-space"></div> </details>
5 ...
6 <div class="-car"></div>
7</div>
(For simplicity, I’ll assume this is a horizontal piece. All the same ideas apply for vertical pieces, just with the directions swapped.)
The basic idea is that the div.-piece
container is a flexbox, everything in a summary
is absolutely-positioned, and each div.-space
is empty but exactly the size of a cell. So the only in-flow contents of the details
are those spacers, and ultimately the flexbox’s width is equal to the number of open details
times the width of a cell. The initial position of a piece is thus set by pre-opening some number of its details
children. (The total number of details
is 6, the size of the board, minus the width of the car — that’s the maximum number of cells it could move over from the left edge.)
The visual part of the car, the div.-car
, is then absolutely positioned as follows:
1left: calc(var(--car-margin) + 100%);
2top: calc(var(--car-margin));
The --car-margin
is a few pixels to keep cars from butting up against the edges of their cells (and thus each other), so that’s not very interesting.
The four edge-positioning properties treat percentages as relative to the size of the containing block. So that left
puts the car’s left edge at the width of the container — which is the div.-piece
flexbox, whose width is the number of open details
, in cells.
So far, so good — now the car’s horizontal position is controlled by the opening and closing of details
, which is something a user can control interactively.
The two elements in each summary
are the two chevrons, one for moving the car left, one for moving it right. The rules of the game (and, for the original board game, the rules of physical reality) say that you can’t move a piece if it’s up against another piece or the edge of the board. That’s not a huge problem, though, because these chevrons are the only controls the user has access to, so they don’t need to be hidden or disabled, only inaccessible. Giving the inner board element overflow: hidden;
will chop off any arrows that peek over an edge, and giving the cars z-index: 1;
to stack them on top of neighboring cars’ arrows.
That just leaves the complicated part!
Clicking on a left chevron should always close a details
, and clicking on a right chevron should always open one. This is a fundamental rule for wiring the controls to the game model: revealing a spacer moves the car right, and hiding one moves the car left. But this isn’t how the summary
element works; its contents act purely as a toggle. Clicking the same left chevron will simply toggle its parent open or closed.
To fix this, the game needs to somehow only show the left chevron for an open details
, and only show the right chevron for a closed details
. Under normal circumstances, this would be easy to do with a details[open]
selector. The result might be a bunch of chevrons stacked on top of each other, but they’re identical and opaque so that’s not a problem, and it doesn’t really matter which element is opened or closed.
Alas! I originally wrote this for Cohost, where stylesheets (and thus selectors) weren’t an option. Instead, I engaged in some popular CSS crime shenanigans, known as width hacking.
Consider the red car from the initial puzzle. It starts in the fourth column, so it has three details
already open. I’m going to add some additional requirements here and say that only one of each chevron should be visible: the left chevron for the last open details
, and the right chevron for the first closed details
. Here, that’s the third and fourth, respectively.
First, each details
is indexed from 0 with a --step
property, so they can be distinguished with calc()
. Consider the right chevron from the fourth details
, the one that should be visible. It has --step: 3;
, and it will only be visible when the car is three cells over, so its position can be written as:
1left: calc(var(--cell-size) * (var(--step) + var(--piece-size) + 0.1));
That’s 3 columns over to match the position of the car, 2 more columns to get to the right edge of the car, and then 0.1 to give the chevron a little breathing room. (That’s important because the cars aren’t flush with the edges of the cells, so the chevrons can’t be either, or the cars wouldn’t fully cover them.)
Now here’s the trick: to ensure this chevron is only visible when the car is actually in this position, add an expression that computes to zero when that’s true, and any large value when it’s not. As long as the large value exceeds the width of the board, the chevron will be shifted beyond its bounds and erased by the overflow: hidden
that’s already on the board.
This is possible because two related values are available: --step
is where the car should be, and 100%
is where the car actually is. Therefore 100% - var(--step) * var(--cell-size)
gives the pixel distance between the car’s actual position and its desired position, and when the car is in its correct position, that’s zero.
When it’s not zero, that distance is at least one cell width (possibly negative). To turn it into a “large value”, just multiply the whole expression by at least 6, the width of the board. I chose 10 because it stands out, giving:
1left: calc(var(--cell-size) * (var(--step) + var(--piece-size) + 0.1) + (100% - var(--step) * var(--cell-size)) * 10);
Rearranging a bit:
1left: calc(var(--cell-size) * (var(--step) + var(--piece-size) + 0.1) + 1000% - var(--cell-size) * 10 * var(--step));
2left: calc(var(--cell-size) * (var(--step) + var(--piece-size) + 0.1 - 10 * var(--step)) + 1000%);
3left: calc(var(--cell-size) * (var(--step) * -9 + var(--piece-size) + 0.1) + 1000%);
And that’s pretty much what’s in the stylesheet.
The only remaining detail is detecting a win, but that’s pretty easy. The red car gets one extra details
, which also contains an element spanning the whole board, both congratulating the player and preventing them from making any further moves. Its spacer is a little narrower, so it only makes the red car peek a little ways into the border.
And that’s it!