Square Zero: hide silly messages in decorative borders

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?

Our New Years card, featuring images of Libby and myself inside a patterned black and white border. It reads "Hope you soar in 2024".

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.

Your turn

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.

How it works

While the final product is simple, there were a few technical hurdles to clear:

Border plans

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)

Dachs in space

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.

Two white concentric circles, the inner representing the joystick and the
outer representing its limits

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.

Full service

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).

Results

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?

Andrew at TooManyGames 2024

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...


  1. Mailed 2/19, naturally. ↩︎

  2. [Ed. Note] Almost certainly I do know you and you're reading this as a token of friendship. ↩︎

  3. You - a stranger2! - I will free from pain; my friends and family I will gleefully torture. ↩︎

  4. The current high score is 9700. ↩︎

  5. I wasn't going to manually lay out 104 little cubes. ↩︎

  6. 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↩︎

  7. Humorous that the imprecision of representing numbers in binary is making our binary encoding more difficult. ↩︎

  8. A reasonable reaction. ↩︎