2024-12-04
I decided to include an Easter Egg in our New Years greeting this year1. If you managed to decipher it, you'd be treated to a retro mini-game based on the card. Can you spot it?
If your eye was drawn to the border of the card, congrats. The white and black blocks encode a binary message. The bits run from the top left -> top right -> bottom right -> bottom left. Let me save you some pain3:
01100100 01100001 01101110 01110111 01101001
d a n w i
01101100 01101011 01100101 01110010 01110011
l k e r s
01101111 01101110 0101110 01100011 01101111
o n . c o
01101101 0101111 01110011 01101111 01100001
m / s o a
01110010
r
It spells out danwilkerson.com/soar, which is where you'll find the game4.
Fast-forward a few days weeks months and I've rewritten the border generator
from scratch in vanilla JavaScript using <canvas>
to do the rendering. Take it for a spin:
Read on for some of the nitty-gritty, or skip to Results to read about how the card was received.
While the final product is simple, there were a few technical hurdles to clear:
Laying out the border was a little tricky. Naively, I tried dividing the perimeter by the count of blocks (bits) and handling direction via some state. Because the border sits outside of the image, we also have to remove four bits from the math to get the appropriate size6.
perimeter = 2 * height + 2 * width
distance = 0
direction = 0
bitSize = perimeter / (bits.length - 4)
for bit in bits:
distance += bitSize
if distance > 2 * width + height
drawRect(0, distance, bitSize)
elif distance > width + height
drawRect(distance, width, bitSize)
...
This kind of works, except if perimeter / bits
yields a fractional result -
you can't fill a fraction of a pixel, it's either "black" or "white". Either the
bits end up with gaps or the line is too short.
Drawing inspiration from Bresenham's line algorithm, we can improve our result by accumulating error and adding it to bits as we go.
perimeter = 2 * height + 2 * width
distance = 0
direction = 0
error = 0
bitSize = floor(perimeter / (bits.length - 4))
error = (perimeter / (bits.length - 4)) % 1
accumError = 0
for bit in bits:
accumError += error
while accumError > 1:
distance += 1
--accumError
distance += bitSize
if distance > 2 * width + height
drawRect(0, distance, bitSize)
elif distance > width + height
drawRect(distance, width, bitSize)
...
This helps, but the results are inconsistent depending on the ratio of the width and height - sometimes the error would cause the border to underflow (or overflow) the expected number of bits per side.
We can fix that last issue by populating the four corners, then filling the space between them using the same error algorithm. We can determine how many bits fit on each side by dividing the width and height by the ideal bit size.
heightBits = round(height / bitSize)
heightBitSize = floor(height / bitSize)
heightError = (height / bitSize) % 1
distance = bitSize
for i = 0; i < heightBits; ++i:
height = bitSize + floor(accumError)
accumError = (accumError % 1) +
heightError
// The left side
drawRect(0, distance, height)
// The right side
drawRect(width, distance, height)
distance += height
...
// The same as above, but for the width.
...
This works nicely and gives mostly uniform-looking results. However, we still
have one remaining problem: IEEE 754 floating point.
We should have no remaining error at the end of our loop (after all, (length / points) * points == length),
but floating point imprecision means we'll often end up with error state like
0.99999999999
at our loops conclusion7.
We fix that by manually clamping the last bits to the width
/height
maximums.
heightBits = round(height / bitSize)
heightBitSize = floor(height / bitSize)
heightError = (height / bitSize) % 1
distance = bitSize
for i = 0; i < heightBits; ++i:
...
drawRect(0, distance + 1, height)
drawRect(width, distance + 1, height)
Next, writing the actual game.The game itself is intentionally simple - I wanted anyone to be able to play it and enjoy it. I quickly settled on a 2D scroller, as it seemed accessible and simple to implement.
Desktop controls were easy, but it was surprisingly hard to get it right on mobile. I ended up writing a little "joystick" that would allow the player to move TJ relative to the "knob" - releasing the knob snapped TJ back to the center.
The underlying code is surprisingly simple - The background is animated via CSS, there's a very straight-forward collision detection system, and the music and sound effects I made in BeepBox. The game spawns more and more mail/smoke detectors (good and bad, respectively) the longer it goes on, eventually flooding the screen. This means there is a hypothetical max score! Unfortunately, there's no scorekeeping on my end, so I only know the top players based on screenshots they send me.
Serving the game was more challenging. My site is served via Firebase hosting and (was) powered by Gatsby. While powerful, I had a lot of trouble trying to "just serve 5 kilobytes". After some fiddling/updating/testing, I threw it out and wrote my own static site generator.
As you can guess, this also meant re-implementing large chunks of Gatsby, mostly via the excellent goldmark package. I had to write my own plugin to recreate the responsive image generation that Gatsby did so well. I also added a simple webserver and hot reload functionality. All told, it's about ~600 lines of Go. The whole site (content + assets) compiles in ~10s from a cold start (incremental reloads are ~instant).
So how successful was the card? Well, we sent out about 40 of them; almost no one realized there was a puzzle on the card. Once nudged, most folks realized it was the border, and quite a few guessed binary was involved. At this point I'd suggest decoding it. The most common reply? "I think I'll go on living my life, but thanks"8.
The first to solve the puzzle was the very clever Andrew Sweet, which is no surprise to me. The demanded prize was a visit, which we were happy to oblige. Here's Andrew pointing at something at TooManyGames 2024. Or maybe celebrating his victory?
Overall, the card was a hit. I was surprised at how much people enjoyed the minigame. I was getting screencaps of high scores for weeks. Which reminds me, it's probably time to get started on next year's card...
Mailed 2/19, naturally. ↩︎
[Ed. Note] Almost certainly I do know you and you're reading this as a token of friendship. ↩︎
You - a stranger2! - I will free from pain; my friends and family I will gleefully torture. ↩︎
The current high score is 9700. ↩︎
I wasn't going to manually lay out 104 little cubes. ↩︎
Illustrated, if the inner rectangle was our image and the outer our border:
The image has a perimeter of 3 + 3 + 3 + 3 = 12
, the border 4 + 4 + 4 + 4 = 16
. ↩︎
Humorous that the imprecision of representing numbers in binary is making our binary encoding more difficult. ↩︎
A reasonable reaction. ↩︎