Hi. It’s Nolen (eieio) from the future, a day and half after I posted this.

Since launch, One Million Checkboxes has been played by over half a million people. We’ve checked over 200 million checkboxes. The site was featured in the New York Times! It’s been wild.

So the details here are out of date. I have spun up a whole lot more infrastructure and made a whole lot of changes. It’s a good story! But I haven’t had time to write about it yet.

I’ll do that soon (and I’ll link the writeup from this page). Until then, happy checking :)



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 client1. The client looks at the state to figure out which toggles are set. My original implementation looked like this:

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

  1. I originally RLE encoded this but later decided that just shipping 125kb was fine since it saves my server some work.