Welcome to Suricrasia Online!

"Connecting your world, whenever!"

CSS Puzzle Box 2.0: Technical Breakdown

Back in 2022, I posted a puzzle box to cohost.org that was made entirely using HTML and inline CSS. Now that cohost.org is shuttered, I thought it best to re-release it with added features and bugfixes. Gone are the random Sudoku puzzles, now replaced with actual games! Click here to play.

For those just tuning in, you read all that right. This fully-featured puzzle box uses no JavaScript or CSS selectors in the HTML, just copious amounts of style attributes. In this article I'll walk through how I misused HTML/CSS to achieve this. There's spoilers ahead for what the puzzle box contains, so go and play it if you haven't!

Before we begin, I'd really recommend reading this blog post on desktop. A lot of the interactive features will only work there. In particular, the handles for resizable elements are very difficult to grab on mobile, and on some mobile browsers they don't seem to work at all.

Part 1 — The Basic Toolkit

In order to achieve any amount of interactability we need to use some relatively obscure HTML/CSS features. From these features we can build more complicated primitives like buttons, sliders, combination locks, etc.

The details Element

If you were doing web development in the early 2010s like I was, you might have stumbled across the checkbox hack. This hack allowed you to use a checkbox and a few complicated CSS rules to create a toggleable element. With it you could make dropdown menus, nested folder browsers, push toggles, and more.

Although it was a fun hack, let's be honest: those were dark times. Why can't we just have an HTML element that you can toggle in this way? Thankfully in the 2020s we finally have such an element: details

Here's an example of the details element in action:

Click me! Hello world!
<details>
	<summary>Click me!</summary>
	<b>Hello world!</b>
</details>

The details element hides all of its content except for the summary element. When you click on the summary, it reveals the rest of the content. You can make it expanded by default with the "open" attribute.

I love this element, it's so much nicer than that checkbox hack (though I still love the checkbox hack for its sheer cursed-itude.) As we'll soon see, the details element is the main workhorse of this entire project, making up all the buttons and removable panels. Without it most of the puzzles would simply not be possible.

The resize Property

Have you noticed in the corner of <textarea> elements there's a handle that allows you to resize it? Turns out this can be enabled on any block element, as long as overflow: auto; is set.

<div style="
	width: 80px;
	height: 80px;
	background: yellow;
	border: 1px solid black;
	overflow: auto;
	resize: both;"></div>

The resize handle is pretty hard to see, so I like to add a little moving icon to that corner. This can be done by setting it as a non-tiling background image with its position set to the bottom right.

The resize property can take the values "horizontal" or "vertical" so you can limit the direction of growth. Also, you can use the max-height and min-height properties (and width equivalents) to limit the range of sizes the element can take.

The calc Function

calc is a CSS function that allows us to use formulas in place of values for virtually any property. Crucially, it allows us to mix units. This might not seem too important, but with some clever math you can effectively do if statements based on the size of the parent element. Here is an example, try growing the following element:

If you're on mobile and can't grab the infinitesimally small resize handles, this is what you would see after growing the element to be larger than 100 pixels in width:

An red square suddenly appears inside! This is achieved by using the following calc function for the width property of the inner red div. I've written it into a CSS rule so my blog's syntax highlighter can make it look good.

div.red { width: calc(max(min(100% - 100px, 1px), 0px)*40); }

The way to understand this function is to realize that, when the browser evaluating the calc function, it essentially replaces the value 100% with the actual width of the parent element. If the parent's width is over 100, we get a positive value, and otherwise it's zero or negative.

The max(min(value, 1px), 0px) part then clamps this value to be either zero or one, depending on if the value was negative or positive respectively. We can then multiply that by 40 to get 40px when the parent is over 100px, and 0px otherwise.

Formulas of this type can be composed together to create more complicated range checks. For example, you could write a formula that takes the minimum of two sub-calculations to create a formula that is zero everywhere except for a specific value. You can then take the maximum of multiple versions of those formulas to create one that is zero except for a subset of values. The below example will only show a duck if the div is exactly 80px or 120px in width.

This trick assumes the CSS pixels are integers (which isn't always true!) but the CSS puzzle box is written such that everything is aligned per-pixel, so it should always work.

The backdrop-filter and mix-blend-mode Properties

With backdrop-filter you can do Photoshop-style filters on the elements below. A large set of filters are available, such as brightness/contrast, blurring, saturating, and hue-rotation. Blow is an example of blur:

<div style="
	width: 100px;
	height: 100px;
	border: 1px solid black;
	background: url('./ducky.png');">
	<div style="
		width: 50px;
		height: 50px;
		margin: 25px;
		backdrop-filter: blur(10px);">
	</div>
</div>

With mix-blend-mode you can change how the element blends with the elements below it. For example, you could have an element "invert" the elements below it by using "difference" as the mix-blend-mode. Below is an example, note that the background for the inverting div has to be white so that it has some content to actually invert with:

<div style="
	width: 100px;
	height: 100px;
	border: 1px solid black;
	background: url('./ducky.png');">
	<div style="
		width: 50px;
		height: 50px;
		margin: 25px;
		background: white;
		mix-blend-mode: difference;">
	</div>
</div>

Part 2 — Creating Abominations

Now that we've reviewed the relevant tools in our arsenal, it's now time to put them to use to create more complicated gadgets.

Width-Hacking

A substantial amount of the puzzle box is enabled by a technique that cohost.org user @corncycle dubbed "width-hacking." @corncycle wrote an excellent tutorial on how it works, so take a look!

If you don't want to take a look at the aforementioned excellent tutorial, I'll explain width-hacking briefly/poorly. The main idea is to combine the use of the details element and our calc formula trickery, allowing us to hide/show elements depending on which combination of details elements are open.

The following example is based on our earlier calc example, the one where a red div appears when the yellow div is over 100 pixels in width. However, now the yellow div is no longer resizable. Instead, it has a details element inside with a blue summary element. When you click on the summary element, it reveals a 3px wide div. That div causes the width of the yellow div to exceed 100px, which—through the magic of calc—causes the red div to appear.

<div style="
	padding-left: 98px;
	display: inline-flex;
	height: 80px;
	background: yellow;
	position: relative;
	border: 1px solid black;">
	<details>
		<summary style="
			color: white;
			position: absolute;
			top: 0;
			left: 0;
			height: 20px;
			width: 20px;
			background: blue;">
		</summary>
		<div style="width: 3px;"></div>
	</details>
	<div style="
		background: red;
		height: 40px;
		position: absolute;
		bottom: 0;
		left: 0;
		width: calc(max(min(100% - 100px, 1px), 0px)*40);">
	</div>
</div>

This might seem like an overengineered way to show a red div—we could've just put it inside the details element, after all. However, we can now easily achieve something that used to be impossible. Suppose we want to have three buttons, and we want the red element to only appear if all of them have been clicked. With this technique it is trivial:

I won't bore you with the code for this one, you can always check it out with "view-source." The only difference is that the details elements reveal 1px wide divs now, and there are three of them.

This width-hacking business is a deceptively powerful tool. With a different calc formula, we can make it so the red div only shows when exactly two of the buttons are pressed. Or we can make it so that instead of showing 1px-wide divs, the three details elements reveal 1px, 2px and 4px-wide elements respectively. Then you could write a calc formula that only shows the red box when an exact combination of those summary elements have been clicked. The sky's the limit.

The next few gizmos are direct applications of width-hacking.

Self-Destructing Buttons

In some cases we want a button that disappears forever the moment the player clicks on it. To achieve this, we make the width of our summary element depend on the width of its parent. Consider the following:

<details open style="
	display: inline-flex;
	position: relative;
	height: 20px;">
	<summary style="
		color: white;
		position: absolute;
		top: 0;
		left: 0;
		height: 20px;
		width: calc(100% * 20);
		overflow: hidden;
		background: blue;">			
	</summary>
	<div style="width: 1px;"></div>
</details>

Here we're setting the width of the summary element based on the width of the details element. The details element is open by default, so the inner 1px div is visible and therefore the details element has a width of 1px. When the summary element is clicked, the 1px div disappears, and the width of everything becomes zero. No more button!

We can add additional divs inside the details element and use them to cover up other elements. This makes it so those elements are covered until the button self-destructs. This is how this old cohost post of mine worked. When you click on a button, it also removes a div covering up another button. This forces you to click the buttons in the correct sequence.

N-State Buttons

An N-state button is a button that cycles through N states as you click on it. For example, the rotation button on the matching puzzle's tiles are an 4-state button; clicking on it cycles through the four possible rotations for that tile.

A 2-state button is pretty straightforward to implement. It's not much different from what we've seen so far. If you want a more in-depth explanation of how this is done, here is a tutorial I wrote on cohost.

The case for N > 2 is more complex. The implementation I came up with uses width-hacking in a way I haven't covered yet. Instead of the width of the parent controlling the width of a child, we use the parent's width to control the child's location.

Consider the following 4-state button, implemented using my method:

I won't repeat the code here, it's too long. Instead, I would rather you take a look at it in the inspector. As you click, you'll see that there are two details elements that take turns switching open or closed. This is what creates the 4 states. To make it more clear what is happening, let me turn off the overflow: hidden; property on the main div, and add some borders/offsets so you can see what is happening:

click
here

The dashed-bordered rectangle is the first summary element, and the dotted-bordered rectangle is the second summary element. As you can see by clicking, the summary elements position themselves so the next click will land on the correct element.

This positioning dance is done by setting the summary's left property to a calc formula based on the width of the parent div. As the div gets wider due to the details elements revealing their inner divs, the summary elements jump to the left.

A consequence of this method is that the states come in a weird order. As you can see by the previous example, the numbers are arranged in the order "1243." That's because halfway through the cycle we jump to the end and start going in reverse order, since we need to close all the details elements we just opened. This isn't too big of a deal, we just need to account for this if we want to add additional divs that hide/show themselves at specific states.

If you've been following along closely you may have noticed that this method only works for even-numbered N. This is true, I haven't actually found a good way to do this for odd-numbered N, instead I simply do it for N*2. Unfortunately, that means for the odd-numbered case, each state doesn't get a unique width value.

Part 3 — Putting it all Together

Ok, time to get into the nitty gritty details of the actual puzzles. I won't cover the connective tissue between the puzzles, like the drawers and the screw, since these are just resizable elements and cleverly placed self-destructing buttons.

Combination Lock

The combination lock is usually the first component a player will try to interact with. It is also the most straightforward to implement given the tools I have already explained.

Each digit in the combination lock is its own 10-state button. This becomes clear once you see that its spritesheet has the characteristic strange ordering:

spritesheet for the combination lock digits, showing that halfway through the order reverses.

To the right of the combination lock are two red buttons. When the correct digits are selected, one of those buttons lights up and becomes clickable. Those are actually self-destructing buttons, and clicking on them reveals the next stage of the puzzle box. They're also technically always visible—they only appear unlit because I've covered them with divs whose background is an image of an unlit button.

Each digit in the combination lock has two of these "covering divs," which I'll refer to as "shutters" going forward. Each shutter covers one of the two glowing red buttons. When the correct digit is selected, the shutter's width becomes zero and the button is uncovered. However, because each digit has its own shutter covering that spot, this means all of them have to be correct for the light-up button to be visible and clickable.

Here's a single one of those combination lock digits, together with its shutters. Try the values '6' and '9':

When you punch in the right code into the combination lock, all of those shutters disappear, revealing the self-destructing button underneath. Clicking it also removes a div that was covering one of the wooden panels and allows you to access the next puzzle.

Lights On Puzzle

The main insight for this puzzle comes from cohost.org user @porglezomp.

The idea is to have an array of details elements for each individual button. Each of these contain one white div that covers itself, and one white div for each of its neighbours. These divs are visible depending on the state of the element, and thanks to the mix-blend-mode: difference style they will invert whatever colour is underneath them. This is enough to give us the classic "lights-out" behaviour. We must also add the pointer-events: none style so click events pass through to the actual buttons underneath. Here is a barebones implementation for 4x4:

To improve the look of the buttons, two images are layered over-top with different blend modes. The first layer is set to "multiply" and controls where on the button the light should appear. The second layer adds the actual button texture using the "plus-lighter" blend mode (equivalent to pixel-wise addition.)

multiply layer for the buttons. addition layer for the buttons.
First: the multiply layer. Second: the addition layer.

This looks good, but we need a mechanism for detecting when the puzzle is completed. For the puzzle box to work as a game we need to stop the player from advancing until they actually solve the puzzle. @porglezomp's post didn't have this, so we need to invent a way ourselves.

First of all, which combination of open/closed details elements actually correspond to a solution? All of them being open doesn't work, since they interfere with each other. The answer isn't obvious, so I wrote some python code to iterate over every single game state to find which correspond to a valid solution. Surprisingly, there are only 5 of these states, shown below. The green 1s correspond to open details elements, while the 0s are closed.

diagram showing the 5 valid solutions to the lights on puzzle.
That last one reminds me of Magnasanti...

Now we need a way to encode the game state in a way we can use. The plan is to use some kind of width-hacking here, so this means using the width or height to encode the state. My initial idea was to make each button reveal a div whose width is 2npx, where n is the index of the button. If we have 25 buttons in the 5x5 grid, then the lights-out div can take 225 possible widths. Each possible width would then correspond directly to the 225 possible states the game can be in.

Unfortunately a web browser can't really handle widths on the order of 225. Any calc function using that width kinda breaks down. I assume this is due to floating point errors.

So instead, we will encode the state of the game into both the width and the height of the lights-out div. The first 12 buttons will contribute to the width of the div, so it will max out at 212px. The last 13 buttons contribute to the height, which will max out at 213px. These sizes are no problem for the browser to handle.

Now we need a way to prevent the player from continuing until one of these game states are reached. We'll do an identical thing as the combination lock: Included in the puzzle is a self-destructing glowing purple button that, when clicked, removes a div that was covering another part of the puzzle box. This pattern is used for the other puzzles.

screenshot of the puzzle box showing the glowing purple button.
The glowing purple button that reveals the next puzzle.

Unfortunately, because self-destructing buttons themselves use width-hacking, we can't have its visibility be contingent on the width of the lights-out div. It needs its own parent div to track its state. This means we'll use our trusty "shutter" technology to cover the purple button with an unlit version until the player arrives at a valid solution.

It's easy to make a div that only appears for a specific (width, height) combination, just make the width/height zero if the parent's width/height isn't the right value. But since the shutters cover the purple button, they must only be shown for invalid game states. This puts us into a little bit of a pickle. So far I haven't figured out a way to hide a div for a particular (width, height) combination. This might be surprising (couldn't we just invert our calc formulas?) but convincing you of this limitation would take too long, so I leave it as an exercise to the reader. If you find out an elegant way to do it, please let me know!

Back to the problem at hand. If we don't care at all about the final file size of the puzzle box, then we could just make a shutter for every single invalid game state. This would mean 225 - 5 shutters. Entirely too many!

Thankfully many of these shutters are redundant, and we can "merge" them together to reduce the amount required substantially. Consider the schematic below:

diagram showing many yellow dotted boxes representing each shutter, and Xes for the solutions.

In this schematic, the (x, y) coordinates represent particular (width, height) combinations of the lights-out div—and therefore particular game states. Each dashed yellow box represents a shutter that only becomes visible for that particular game state, and all the "X" marks represent valid solutions which don't get a shutter.

If we have one shutter that handles case (x, y), and another that handles (x+1, y), then we can combine them together into a single shutter that handles both cases. We don't even need to make its calc formula more complicated, we just have to change the constants to cover a larger range.

We can merge most of the shutters in the following way:

diagram showing how the shutters can be combined to massively reduce their number.

Now there are only about three shutters per solution. That's a massive improvement, and with that our win-detection works perfectly.

When I originally posted the puzzle box to cohost, the solution checking was implemented in a different, extremely complicated way. Each row of buttons would get its own div, and using some complicated grid magic and the aspect-ratio property I was able to launder information about the correctness of each row into the height. However, I completely forgot the specifics of how this worked, and the puzzle would break at different browser zoom levels. So for this re-release I rewrote it, arriving at this much more elegant solution.

Nonogram Puzzle

Although I'm most proud of the nonogram puzzle aesthetically and functionally, it is only slightly more complicated than the combination lock. In a sense, it's just a lock whose combination is whether a cell is filled or unfilled/marked. Each cell has its own shutter that covers up the glowing purple button unless it's in the right state.

However, there is some technical novelty here: The cells of the puzzle are 3-state buttons, which is an odd number, so it's implemented as a 6-state button. This means that each state of the button is duplicated, so we need to include the images for filled, unfilled, and marked states twice in the spritesheet.

fill types for the cells in the nonogram puzzle.
Filled, unfilled, and marked.
spritesheet for the nonogram buttons, showing the characteristic reordering.
The spritesheet for the actual button.

We need to be a little careful so that our win-checking works. If our shutters only show for a specific width for the parent element, then they'll miss the secondary, duplicated state. Thankfully this can be fixed by putting the filled states in the middle of the spritesheet. That way we can do some simple calc magic to include both states.

Maze Game

There isn't much to the maze game that isn't already covered in @corncycle's wonderful width-hacking tutorial. Go read it!

The major difference is that, for my player movement, I have to enforce the topology of a triangular grid. To do this, I essentially make "walls" for the down/up directions when you are on a triangle pointing up/down, respectively. The premise is that if you're in a triangle, you should only be able to exit through the edges. If the triangle is pointing up, then you shouldn't be able to travel upward since that would mean passing through its vertex.

In the following diagram we see the neighbourhood of a particular triangular cell. The leftward, rightward, and downward directions are allowed, but the upward direction is disallowed because it passes through a vertex.

an illustration showing how the triangular grid's topology is enforced.

An additional detail is how we render the player's position. I want it so the triangular cell they're occupying glows white as if it's been filled-in. Depending on the cell this triangle will either be pointing up or down. Recall from @corncycle's tutorial that there is a div whose width and height represents the player's position. I made it so the size of the player is proportional to the parent's div size. We then set the background-position property to be a percentage of that width/height. This will offset the following spritesheet depending on the player's location:

the character's spritesheet, a 2x2 grid of white triangles on a transparent background. The top left an bottom right corners have the triangle pointing down, and the top right and bottom left corners have the triangle pointing up.

Each additional pixel of width shoots the background one sprite to the left, and for each additional pixel of height we move the background one sprite down. Thanks to the fact that backgrounds wrap-around, this will show a different sprite depending on the parity of the player's (x,y) coordinate. I use the clip-path property to trim the div to the original sprite size. This hides the fact that the player's div is getting larger.

Unfortunately this background-position stuff breaks down for some zoom levels on chrome. At %110 in particular the image offset is wrong and it ends up glitching out. To fix this I had to reimplement the character using a technique I will cover in the next section.

Finally, the messages shown on the bottom screen are just divs with special calc logic in their sizes so they only appear for specific player locations.

Tower of Hanoi

Believe it or not, the tower of Hanoi (ToH) game's logic is very similar to that of the maze game! Take a look at a diagram below, from Wikipedia, showing how the game states for ToH are connected.

diagram showing that the different states of ToH are connected in a recursive triangle.
Source

Each node represents a particular possible arrangement of the discs, and each edge represents a valid move.

This graph is also technically incomplete. In the center of each edge is an implicit state where the player is holding the disc, deciding where it should go. I found that if you redraw the graph with these implicit nodes added, then it becomes a subgraph of the square grid. This implies that, with the correct placement of walls, we can create a maze whose rooms and passages correspond directly to states and moves of the ToH game.

How exactly to do this mapping is a different matter. My notes offer only the following vague diagram, where the filled circles are valid positions, and the "x" marks represent positions where a disc is held.

vauge sketch showing how, by adding additional states, the game-space of the tower of Hanoi game can be arranged on a grid.
Your guess is as good as mine.

I recall spending a lot of time trying to come up with a closed-form solution for how to place the elements of the game state onto a grid. Ultimately, I gave up and wrote a brute-forcer. Here's a screenshot of it trying to rearrange the nodes of the game-state graph so that all the connections are ortholinear.

screenshot showing the discs in the tower of hanoi game being messed up.
There has to be a better use for all this compute power...

After several hours of computation I eventually found a valid arrangement. With that I can convert it to a corncycle-style maze game. However, unlike those mazes where "walls" are implemented as divs that occlude the arrow keys, for this game I do away with arrow keys entirely. Instead each summary element has a number of divs inside which only appear for specific game states. These divs are placed under the tower that corresponds to the state transition it will perform.

Now onto displaying the discs themselves. I originally had a full implementation that used some weird spritesheet stuff, but then I thought "there's gotta be a better way!" and hyperfocused for six hours on the task. In the details tag below I describe the old way, you can skip it if you want.

Read the out-of-date version

My first idea would be to have a spritesheet that contains an image of every possible game state. Unfortunately this spritesheet would be massive, so I had to find a smarter way.

Instead, I developed a kind of "compressed spritesheet." Instead of storing images of the game directly, instead each pixel of a game state's sprite represents a different thing. One pixel might represent if a disc of radius 2 or larger is in the bottom slot of the left tower. Another might represent if a disc of radius 3 or larger is in the second-last slot of the middle tower.

the sprite sheet as described.
The sprite sheet in question.

In general, the spritesheet encodes—for a given game state (x,y)—whether or not a disc of size A or larger is in position B on tower C, plus if a disc of size A or larger is being held. By using background-position and clip-path magic, we can make divs that grab specific pixels from the sprite sheet and make them arbitrarily large. This means we can have divs whose opacity changes depending on the game state, indexing into a particular detail we are interested in.

With this power, we can essentially reconstruct a proper graphical representation of the game state by copying/stretching particular pixels and putting them in the right spot. We can then add the following filter stack to the container, which gives a nice double-outline:

div.toh_pixels {
	filter:
		drop-shadow(3px 0px 0px white)
		drop-shadow(-3px 0px 0px white)
		drop-shadow(0px 3px 0px white)
		drop-shadow(0px -3px 0px white)
		drop-shadow(3px 0px 0px black)
		drop-shadow(-3px 0px 0px black)
		drop-shadow(0px 3px 0px black)
		drop-shadow(0px -3px 0px black);
}

Unfortunately, this is the part of the puzzle box that breaks when you have a non-integer scale. The background position calculation isn't able to grab the right pixel accurately, and you get something like the following mess:

screenshot showing the discs in the tower of hanoi game being messed up.
Uh oh!

Scaling the spritesheet by 300% and grabbing the centermost pixel for each 3x3 area helps a bit, but it's still unfortunately a problem. So I tried to find a better way...

So what is this better way? Before I tell you, I anticipate objections to my claim that this puzzle box uses "only inline CSS." The HTML file delivered by my server does indeed contain no CSS selectors or stylesheets. But, I only said that there are no "CSS selectors in the HTML." By cohost.org rules, you can put CSS stylesheets inside SVG images and use them as backgrounds. The SVG can then use media queries to query the size of the div it's the background of. With this power you can do some pretty wild stuff.

Sure, this isn't a "purely inline CSS" puzzle box anymore. But hey, if this is going to be my last hurrah for cohost, then I might as well use all of the tech we discovered. The first person I recall using this technique was @ticky, who used it to change an image depending on the width of a resizable div.

One thing that has always been a struggle for using SVG backgrounds is its scaling logic. SVGs, by default, scale and center themselves to fit the area they are given. We don't want this, so we've always needed to add an inverse CSS transform to cancel-out that behaviour—and that doesn't work so well when both the width and height are changing. Due to these limitations it always seemed like it wouldn't be possible to communicate both width and height to the background SVG in a usable way.

However after revisiting this project I came up with a solution! There is an attribute for the main svg element called preserveAspectRatio. If we set this to "none", then the SVG will stretch to fit the containing div, ignoring its own aspect ratio. This seems bad, but with the media queries we can directly undo this stretching ourselves.

Consider the following SVG with a width/height/viewbox of 50px by 50px:

<svg width="50" height="50" version="1.1" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<g id="container">
	<rect x="20" y="20" width="10" height="10" fill="blue"/>
</g>
</svg>

If we set it as the background of a 50px by 50px div, then it will appear as intended. But if we resize the div to 100px by 100px it will be stretched to twice the size.

This stretching is undesirable. We don't actually want the content to scale, we just want to sneak the width/height into the SVG so we can enable or disable different styles and content. To fix this, we'll undo the stretch ourselves by adding the following stylesheet:

svg, #container {
    transform-origin: top left;
}
@media (height = 100px) {
	svg {
		transform: scale(1, 0.5);
	}
}
@media (width = 100px) {
	#container {
		transform: scale(0.5, 1);
	}
}

Note that there is a group element with the ID #container that wraps the SVG contents. We scale down the Y dimension by squishing the svg element, and we scale down in the X dimension by squishing the #container. We can enumerate over all the possible width/height values we expect our SVG to take and generate a media query for each. Because the horizontal and vertical scaling are separate, this means our div can take the sizes 50px by 100px and 100px by 50px and the background SVG still look correct.

It's remarkable how well this works. I would've expected it to jitter around due to floating point rounding errors, but I've noticed no such thing. This tech would've sold like hotcakes on cohost back in the day.

Anyway, now that we're in SVG land, we can do some other fun things. In particular, we can add the transition style to various elements. If an element's animated style changes because a different media query activates, then it will actually animate! This is how the discs on the towers are able to slide smoothly to the different positions.

One thing to be aware of is you'll need to add the following CSS/element to the SVG for it to animate properly:

@keyframes hack { from {fill: #111;} to {fill: #000;}}
#hack { animation: hack 1s infinite; }
<g id="hack"><rect height="1" width="1" x="1" y="1"/></g>

The reason for that is because web browsers only render the SVG as an animation if it detects its using those features. This detection is somewhat buggy (it doesn't account for the transition property) so we have to add a placeholder animation to force it to recognize what we're doing.

With all that said, I can finally explain how the discs are displayed. For each disc I have two image elements. The first element is the foreground, and the second is the background. This is so they're able to fit over the pole correctly. Each image is also nested inside their own group element. This is so I can animate the X and Y movement at different rates. The discs and their g elements are placed at the correct location using translate properties inside media queries like so:

@media(width=398px) and (height=181px){
	.d3{translate:0px 125px}
	.dd3{translate:59px 0px}
	.d2{translate:0px 125px}
	.dd2{translate:185px 0px}
	.d1{translate:0px 111px}
	.dd1{translate:185px 0px}
	.d0{translate:0px 125px}
	.dd0{translate:311px 0px}
}

These widths/heights directly correspond to the game state (plus some offset) so each possible position in the game is represented here. This seems like it would be very space inefficient, but it's not too bad. The final size of the SVG (with all its styles and embedded images) comes to ~50KB.

This SVG stuff is also how I reimplemented the character in the maze game. The character's background isn't a sprite sheet anymore. It's now an SVG has a triangle inside, which gets flipped depending on the parity of the player location.

As the final piece of the ToH game, I have some divs which only appear when all the discs are in a single tower. If the discs are all in the center, the next code for the combo lock is revealed.

Tile Matching Puzzle

This is by far the most complicated and unique puzzle of the bunch. To explain, we're going to build a 1-dimensional version of it. Consider the following "puzzle." Each bar is resizable, and the arrows show which direction they should be resized.

Here's a video of it in action, in case you're on mobile:

When all the bars are in the ideal position, a yellow square is revealed.

This "puzzle" illustrates a way to control an object's visibility based on the dimensions of otherwise unconnected resizable elements. This is done by having a set of "blinders" attached to each resizable element. These "blinders" are a pair of div tags that cover up that yellow square, with a precisely positioned gap which reveals the square when the resizable element is the target size.

Here's a copy of the previous game, except I've altered the styles so the blinders aren't on-top of each other. This should make it easier to visualize what is going on:

The tile-matching puzzle is essentially a 2d version of this. The position of each tile is controlled by a resizable element, and attached to this element is a blinder that uncovers the purple button only if the tile is in the right spot.

Unfortunately, as you'll notice in the previous example, the yellow square is gradually revealed as the player adjusts the final bar. This is less than ideal. What I would like is for the button to immediately snap from "totally off" to "totally on," with no in-between.

To fix this, I instead have two different kinds of blinder for each tile that do different things. The first kind—which I'll call the finger-blockers—stops the user from clicking on the button before the puzzle is finished. The finger-blockers are transparent, but nonetheless occlude the button and stop click events from passing through. The gap in the finger-blockers are a little bit larger than the button itself, allowing the tiles to be off-center by ±10px.

The second kind of blinder are what I'll call the light-blockers. These have a much smaller gap—only 10px wide/high. Underneath all the light-blockers is a white div. When the tiles are within ±10px of their correct position, some of the white will poke through, producing a tiny white square that is visible to the player.

A tiny square of white isn't good enough, so we'll use a trick. If we use the backdrop-filter property to blur the square, it will smear the white across neighbouring pixels. If we then apply an extremely aggressive brightness ramp, then all those neighbouring pixels will be brought to full brightness. Repeating this sequence of filters multiple times will cause even a solitary white pixel to explode outward, filling a div of arbitrary size. The full filter stack looks like this:

.blurrer {
	backdrop-filter:
		blur(1px) brightness(5000)
		blur(2px) brightness(5000)
		blur(4px) brightness(5000)
		blur(8px) brightness(5000);
}
each step of the filter stack, showing a single area of white growing to fill the button.
Each blur+brightness step of the filter stack.

Once we have a field of white, we do a similar trick from the lights out game (the multiply/addition layers) to make the button look nice.

The final detail is that the tiles can't just be in the right spot, they also need the right rotation. This is achieved with an additional div that covers the gap in the blinders. This div uses run-of-the-mill calc magic so it is only visible when the tile is in the correct rotation.

Part 4 — Creative Process

Aside from some models from blendswap, all of the 3d modelling was done by me in blender. To prepare the rendered elements, I used a combination of gnu-imp and imagemagick to cut/slice/montage/colour-correct.

To actually generate the HTML/CSS I wrote an inscrutable heap of python code. There's helper functions for placing the drawers, self-destructing buttons, etc, and single-use code for generating the specific puzzles. It will take some nontrivial effort to clean it up into a releasable state, so that will have to come later.

What follows are a bunch of work-in-progress pictures. Notebook sketches, blender screenshots, etc. Click to reveal the gallery. (Warning: big spoilers!)

Open gallery
screenshot from blender showing the main puzzle box and its interior puzzles. screenshot from blender showing the pop-out drawers with the handheld games inside. screenshot from blender showing the final drawer that says 'good job'. an extremely early sketch of the puzzle box concept, showing things like the combination box and pop-out boxes. a more accurate sketch of the puzzle box, laying out the kinds of puzzles which will appear and how they lead into each another. sketch of an early version of the puzzle box, but with brainstorming for the tile matching puzzle. initial design for the nonogram game. notes working through how the tower of hanoi game states interrelate, plus a sketch. more tower of hanoi stuff, trying to figure out how the states relate to their neighbours. sketch and plan for how the triangular maze game will look/work. a schematic of the puzzle box, which I used as a reference for how/where things should be placed. mockup of how the tower of hanoi game would look. mockup of how the maze game would look.

Part 5 — Final Thoughts

I'm really glad this thing was so well received when I posted it to cohost. I've lost track how how many hours I've put into this project, both before and after the original release. The cohost version of the puzzle box had two sudokus in the drawers instead of the handheld games. This was partly because I didn't want to over-scope, and also due to cohost's post size limit (which I was already very close to.)

I'd like to thank the cohost CSS demoscene community for being such an excellent group of cursed hackers. I'd love to link to the #interactable tag, but the internet archive only picks up the first page, so I don't think there is an easy way to get a list of all the CSS interactables that were on the site.

Cohost may be gone, but the memories aren't. The puzzle box would've never been possible had it not been for everyone pushing the state of the art in CSS crimes. Thank you all for your art and skills. Hopefully one day we'll get to meet again somewhere to create weird art together. ♥️


← Back