One Million Checkboxes
One million checkboxes that anyone can check
Jun 25, 2024
Hi. It's Nolen (eieio) from the future, a day and half after I posted this.
While it was live, One Million Checkboxes was played by over half a million people. Over 650 million boxes were checked. The site was featured in the New York Times and has a Wikipedia page. Wild stuff.
This post discusses the original implementation. You can read about how I scaled up the site here, and you can read a story of some incredible emmergent gameplay from the site here.
The original blog post continues below.
I made a website. It’s called One Million Checkboxes. It has one million checkboxes on it. Checking a box checks that box for everyone (and makes some numbers go up).
You can find it at onemillioncheckboxes.com.
checking some boxes
Why
I don’t really know. The idea came up in a conversation with my friend Neal last Friday and I felt compelled to make it.
How
There are a few fun tricks I used here.
- To efficiently store state I use a bit array. Checking box 0 just flips the first bit in that array.
- I store my state in redis since redis can easily flip individual bits of a value.
- I broadcast individual “toggle” updates via websockets and push out a full state snapshot every 30 seconds or so to make sure clients stay synched.
- I use react-window to avoid rendering checkboxes that aren’t in view.
Is there anything else you’d like to tell us
Not much! This one was fun and fast. I did run into one bug that was baffling - I’ll tell you about it really quick.
Endianness
When we toggle a checkbox, the server does something like this:
def set_bit(index, value):
state['bitset'][index] = value
Pretty simple! We convert that state to a big array of bytes and ship it to the client 1. The client looks at the state to figure out which toggles are set. My original implementation looked like this:
I originally RLE encoded this but later decided that just shipping 125kb was fine since it saves my server some work.
class BitSet {
constructor(size) {
this.size = size;
this.bits = new Uint32Array(Math.ceil(size / 32));
}
get(index) {
const arrayIndex = Math.floor(index / 32);
const bitIndex = index % 32;
return (this.bits[arrayIndex] & (1 << bitIndex)) !== 0;
}
set(index) {
const arrayIndex = Math.floor(index / 32);
const bitIndex = index % 32;
const mask = 1 << bitIndex;
this.bits[arrayIndex] |= mask;
}
}
Does this work?
No! Look at what we get from each of these implementations when we set the first bit:
def set_bit(index, value):
state['bitset'][index] = value
state = { "bitset": [0 for _ in range(32)] }
set_bit(0, 1)
print("".join(str(x) for x in state["bitset"]))
# '10000000000000000000000000000000'
int("".join(str(x) for x in state["bitset"]), 2)
# 2147483648
> bitset = new BitSet(32)
> bitset.set(0)
> bitset
BitSet { size: 32, bits: Uint32Array(1) [ 1 ] }
Our python implementation treats bit 0 as the leftmost bit of the leftmost byte. In javascript we’re grabbing the rightmost bit of the leftmost byte!
This isn’t quite an endianness problem - really we’re reversing the order of bits in a byte, instead of reversing the order of bytes in a word. But it certainly feels like an endianness bug.
Fixing this meant deciding whether I wanted to model my data as “one million bits” or “125,000 bytes” - and of course realizing that those were two different things. The bug appeared mid-refactor - my original update code hid the error in my data model until you refreshed the page - and so it took me ages to suspect the bit twiddling. In retrospect this was a mistake. Always suspect the bit twiddling.
Wrapping Up
This site was fun to make. It got me thinking about a new space of collaborative experiences that I want to explore.
It was also really nice to make and ship something in two days! I’ve got a few ongoing projects that I’ve been working on for too long and this was a welcome break (maybe I should just ship those projects too).
Anyway. I hope you enjoy the site, and I’ll be back soon with some larger webcam-based projects :)