Back Original

Running Pong in 240 Browser Tabs

What do you do with your unclosed browser tabs? I find that they take up a lot of screen space. So this week I figured out how to run pong inside mine.

putting that space to good use

That’s 240 browser tabs in a tight 8x30 grid. And they’re running pong! The ball and paddles are able to cleanly move between the canvas in the foregrounded window and all of the tabs above.

You can see the (awful) code here. But how does this work?

Inspiration

This project was inspired by my friend Tru, who made a version of Flappy Bird that runs in a favicon 1 (Flappy Favi) last week.

1

A favicon is the little image that a browser shows you in the tab bar when you visit a website.

FlappyFavi is great, but it’s a little hard to see what’s going on because favicons are so small. I figured I could fix that.

My best idea for how to fix this was by drawing an image across multiple tabs. And that gave me a few problems:

Prototyping

My first problem was figuring out how to make a grid of tabs. I started by opening up a chrome window and mashing the “new tab” button until the tabs were really small. That gave me something that looked like this:

A tiny row of tabs. The favicons form a grid.

nice and small

That seemed promising! We’ve got a nice grid. And if I opened a second window and positioned it just right I could add a second row.

But I wanted a big grid. Doing this by hand would be a pain. So I turned to one of my favorite tools: AppleScript.

AppleScript is a powerful and bizarre way to control programs on Mac; you almost write English, but it’s verbose and strict enough that mostly you end up writing Python with a lot of extra words.

But it was a great fit here. I wrote a script that opened up 8 chrome windows with 30 tabs each, carefully positioning each chrome window to stack on top of each other.

I think it's pretty fun to watch this at work!

There were a couple of annoying problems here - for example, chrome tries to re-open your closed tabs, so the script needs to clear those out at the start.

But the code ends up being relatively simple. The core looks like this:


set bounds of newWindow to {x, y, x + width, y + height}
global tabCount
set tabCount to 0

tell newWindow
  set URL of active tab to baseURL & "windowIndex=" & windowNum & "&tabIndex=" & tabCount
end tell

set tabCount to (tabCount + 1)


repeat (numTabs - 1) times
  tell newWindow
    if windowNum is (maxWindows - 1) and tabCount is (numTabs - 1) then
      make new tab with properties {URL:baseUrl & "windowIndex=" & windowNum & "&tabIndex=" & tabCount & "&isMain=true&numWindows=" & maxWindows & "&numTabs=" & numTabs & "&fullWidth=" & fullWidth}
    else
      make new tab with properties {URL:baseURL & "windowIndex=" & windowNum & "&tabIndex=" & tabCount}
    end if
  end tell
  set tabCount to (tabCount + 1)
end repeat

Fast favicon updates

The next problem was around updating favicons.

By default browsers look at some known URLs for favicons. But you can also add an element in the head of your HTML that says “hey, my favicon is here.”

If you update that element, the browser will change the icon. This is how FlappyFavi works. Anecdotally it seems like chrome will update the icon about 4 times a second 2.

2

I’m not sure how other browsers handle this. Notably Firefox lets you upload animated favicons, which would have been really useful here. But Firefox didn’t let me make a grid of tabs small enough that I could draw effectively to it, so I stuck with Chrome. Wish I could have played with animations though!

But it wasn’t clear to me how this would work when a tab was backgrounded. Browsers restrict the resources that a tab has access to when it is backgrounded to improve performance - and most of our tabs wouldn’t be in the foreground!

I did some simple testing with a tiny loop that updated the favicon every 250 ms. And sure enough, that loop only ran ~once a second in a backgrounded tab!

the backgrounded tab is too slow!

My setInterval loop was being throttled! I started kicking around ideas for how to work around this.

My first idea was to abuse the web audio APIs - I know they have good support for audio continuing in the background and you can add some kinds of callbacks to audio code; I tried playing an inaudible tone and putting my code in the audio thread to see if that’d help. But I couldn’t get this working.

So I tried out web workers. Web workers are a way to offload a computationally-heavy task off of the main browser thread (so that you don’t block rendering). And I thought they might be less throttled.

I moved my timer to the web worker and had it post messages back to the main document when my timer triggered. And this worked great!

nice and synced up

The code here is a little long but relatively simple. We have a worker that cycles through emojis and turns them into data URLs that we pass back to the main tab, which updates the favicon.

web worker favicon code


let intervalId = null;
let counter = 0;
const emojis = ["🌞", "🌜", "⭐", "🌎", "🚀"];
let currentIndex = 0;

function drawEmoji(emoji) {
  
  const canvas = new OffscreenCanvas(32, 32);
  const ctx = canvas.getContext("2d");

  ctx.font = "28px serif";
  ctx.fillText(emoji, 2, 24);

  
  canvas.convertToBlob().then((blob) => {
    const reader = new FileReader();
    reader.onloadend = () => {
      counter++;
      postMessage({
        type: "update",
        dataUrl: reader.result,
        counter: counter,
      });
    };
    reader.readAsDataURL(blob);
  });
}

self.onmessage = function (e) {
  if (e.data.command === "start") {
    const interval = e.data.interval;
    if (intervalId) clearInterval(intervalId);

    intervalId = setInterval(() => {
      drawEmoji(emojis[currentIndex]);
      currentIndex = (currentIndex + 1) % emojis.length;
    }, interval);

    
    drawEmoji(emojis[currentIndex]);
  } else if (e.data.command === "stop") {
    if (intervalId) {
      clearInterval(intervalId);
      intervalId = null;
    }
  }
};


worker.onmessage = function (e) {
  if (e.data.type === "update") {
    let link =
      document.querySelector("link[rel*='icon']") ||
      document.createElement("link");
    link.type = "image/x-icon";
    link.rel = "shortcut icon";
    link.href = e.data.dataUrl;
    document.head.appendChild(link);
  }
};

So that gives us quick updates. But if we want something to run across all of our tabs we need those tabs to synchronize. How should our tabs communicate?

Tab communication

Tab communication had two sub problems:

The first problem was relatively easy - you might have noticed the solution in my AppleScript code above. I had my script pass in the current window and tab index as a query parameter. Each tab just needs to extract those query parameters and it knows its window and tab index - basically its x and y coordinates.

The second problem was a little more interesting. The most obvious solution to me was to use websockets - I could have a server that each tab connected to, and that server could tell each tab what to do.

I whipped up a simple proof of concept. On load, tabs (inside a web worker) created a websocket connection to a server and then updated their favicon based on the data the server sent. The server sent two different images, staggered based on whether the tab’s index was even or odd.

This worked ok. I saw two problems:

To address the first point, I moved to broadcast channels - basically a way to distribute information across different tabs on the same domain. This was different from websockets - websockets are 1 to 1 but broadcasting is 1 to many. But that seemed like a better fit for what I was doing anyway.

The second just required a little more code. I taught backgrounded tabs to send a registration message containing their tab and window index over the broadcast channel. The main tab 3 (the one in the foreground, which isn’t throttled) listened for these messages and sent back an ack, after which the backgrounded tab would stop trying to register. And then once the main tab had received registration events for every backgrounded tab, it started running the animation.

3

My applescript added an extra query parameter to tell the last tab opened that it was the main tab, along with parameters telling it how many windows and tabs were opened.

registration code

bc = new BroadcastChannel("bc");
bc.addEventListener("message", (event) => {
  const msg = event.data;
  if (!msg) return;
  else if (
    msg.type === "ack" &&
    msg.tabIndex === tabIndex &&
    msg.windowIndex === windowIndex
  ) {
    clearInterval(regInterval);
    registrationDone = true;
    postMessage({ type: "registration-ack" });
  }
});

regInterval = setInterval(() => {
  bc.postMessage({ type: "register", tabIndex, windowIndex });
}, 1000);


const bc = new BroadcastChannel("bc");
const registrations = {};
if (data && data.type === "register") {
  const key = `tab_${data.tabIndex}_${data.windowIndex}`;
  console.log(`Registered: ${key}`);
  registrations[key] = true;
  bc.postMessage({
    type: "ack",
    tabIndex: data.tabIndex,
    windowIndex: data.windowIndex,
  });

  const expected = numTabs * numWindows;
  if (Object.keys(registrations).length === expected) {
    console.log("All tabs registered. Beginning...");
    runLoopGeneric({
      bc,
      worker,
      numTabs,
      numWindows,
      fullWidth,
      impl: "pong",
    });
  }
}

From canvas to tab bar

Once I had reasonable control over my tabs, I started thinking about what I should actually build. I thought it’d be cool if I could draw something in my foregrounded tab and then have that move “into” the tab bar.

I started with a simple rectangle.

To do this I needed to imagine a canvas that extended from my foregrounded window through all of the favicons above it, and then draw to the favicons as well as the main canvas based on an object’s position.

There’s this quote from Teller (of Penn and Teller) that I think about a lot when I make projects like this.

Sometimes magic is just someone spending more time on something than anyone else might reasonably expect.

And while I want to be careful to not make too much of this comparison - I am no Teller!! - I had it in mind while doing this.

There’s no magic here. Honestly, I just spent a while taking measurements.

Several stacked chrome windows. Measurements indicate how tall and wide various spans (like the distance between the left side of the window and the first tab) are

thank you tldraw

There are 92 pixels between the left side of a chrome window and the first favicon (at least with this many tabs open). And 58 pixels between the bottom of a favicon and the top of the actual window. And favicons are 16x16, and, and, and…

My code takes all of those measurements, along with some information about the number of open tabs and windows, and uses it to:

Code that encodes the pixel measurements from the above image.

this was finicky!

We then simulate a rectangle moving across our “full” canvas. When part of it is below the URL bar, we draw it to our “real canvas.” But we also calculate which parts of it are “above” the URL bar, and broadcast that to our other tabs. Each tab calculates its own pixel coordinates (based on the math we did above) and updates itself with white or black pixels based on where the rectangle is.

rectangle code

function transmitSquareCoords() {
  const copied = { ...square };
  updateMap = {};
  for (let t = 0; t < numTabs - 1; t++) {
    for (let w = 0; w < numWindows; w++) {
      pixels = [];
      const PIXEL_COUNT = 4;
      const FAVICON_SIZE = 16;
      const MULT = FAVICON_SIZE / PIXEL_COUNT;
      for (let yy = 0; yy < PIXEL_COUNT; yy++) {
        for (let xx = 0; xx < PIXEL_COUNT; xx++) {
          let x = tabSingle * t + (tabSingle - 16) / 2 + xx * MULT;
          let y = TOP_TO_FAVICON + HARDCODED_WINDOW_DIFF * w + yy * MULT;
          let thisSquare = {
            x,
            y,
            w: MULT,
            h: MULT,
          };
          if (intersects(thisSquare, copied)) {
            pixels.push(1);
          } else {
            pixels.push(0);
          }
        }
      }
      const key = `tab_${t}_${w}`;
      updateMap[key] = pixels;
    }
  }
  bc.postMessage({ type: "update", pixels: updateMap });
}


function updateFavicon(pixels) {
  const canvas = document.createElement("canvas");
  canvas.width = 4;
  canvas.height = 4;
  const ctx = canvas.getContext("2d");
  for (let i = 0; i < 16; i++) {
    const x = i % 4,
      y = Math.floor(i / 4);
    ctx.fillStyle = pixels[i] ? "#000" : "#fff";
    ctx.fillRect(x, y, 1, 1);
  }
  const faviconURL = canvas.toDataURL("image/png");
  let link = document.querySelector("link[rel='icon']");
  if (!link) {
    link = document.createElement("link");
    link.rel = "icon";
    document.head.appendChild(link);
  }
  link.href = faviconURL;
}

Making it faster

This worked ok! But it used a surprising amount of resources - you can see that because the animation on the canvas starts and stops, even though it’s being drawn inside requestAnimationFrame and should be smooth.

Since the animation was jerky, I figured something on the foregrounded tab’s thread was using too many resources. There wasn’t that much going on there, but I figured maybe I was transmitting too much data.

My main thread computed the state of every favicon pixel and then stuff that into the broadcast channel, which was read by hundreds of tabs. You could imagine a broadcast implementation that did a copy for every tab…maybe that was too much copying? I was skeptical, but had no better guess.

I reworked my code to just broadcast the position of the square and had each tab compute on the fly whether its favicon intersected with the square. But this didn’t seem to help! I was baffled.

I fell back on the age-old technique of disabling different bits of code until stuff worked, and eventually I hit on the issue; I was creating hundreds of favicons a second, and that was too slow.

Basically, my code did something like this in each tab to create a 4x4 black and white image and turn that into a URL that I could point my favicon to.

const ctx = bwCanvas.getContext("2d");
for (let i = 0; i < len; i++) {
  const x = i % width;
  const y = Math.floor(i / width);
  const index = (y * width + x) * 4;
  ctx.fillStyle = pixels[i] ? BLACK : WHITE;
  ctx.fillRect(x, y, 1, 1);
}
return bwCanvas.toDataURL("image/png");

And this code re-ran on each “frame” whether the resulting canvas had changed or not. I had hundreds of tabs that were re-creating tiny white square favicons multiple times a second!

I updated my code to only update the favicon if something changed and performance improved dramatically.

To be honest, I’m still a little confused about why this work slowed down my animation. I understand how doing too much work in other tabs could slow down my whole machine (and I was using a decent amount of CPU), but my machine had plenty of cores to spare! I’m clearly ignorant of some aspect of browser resource usage.

So what do we build?

After getting my moving square working, I spent some time cleaning up my code just enough that I had a little “engine” that I could write my code against. And then I started thinking of games to make.

The first game I thought of was snake. I figured that snake was naturally block-based (which fits well with the favicons) and pretty easy to program. So I wrote the first part of a snake implementation 4.

4

The snake trick were you move it by keeping an array of snake positions and on every tick just chop off the tail and add a new head based on the current direction always delights me

But I quickly ran into a problem. Maybe you can see it.

this was pretty fun to play with

The issue - at least to me - is that snake is too block-based. Some of the beauty of the moving rectangle animation is how it moves from continuous (on the canvas) to discrete (in the tab bar). I think it’s most natural to think of snake as a game operating over a discrete (and small) set of snake cell sized blocks.

So I tried to come up with a new game 5. Eventually I settled on Pong, since the ball and tabs would have to regularly move between the canvas and the tab bar, which I thought was a cool effect.

5

Thanks for everyone on bsky and twitter that offered game ideas without knowing what I was trying to do!

Implementing pong

And then I implemented pong!

To be honest, I don’t have too much to say about this part. I have enough practice writing games that getting pong working once I had a good API for drawing was pretty simple.

I guess I can give you a few notes on my implementation:

I was really happy with the effect of the ball and paddles smoothly sliding into the favicons, and ended up adding a trail to the ball to try to emphasize the movement.

I've stared at this for too long to know if the trail is ugly

The code is open source but extremely ugly; I never really left prototype mode on this one. Sorry about that.

Wrapping up

This was a fun one! Thanks again to Tru for the inspiration.

I wrote this project while in batch at the Recurse Center a (free!) place that is like a writer’s retreat for programming. Recurse is really special, and it motivated me to get this post out the door today so that I could show this project during Recurse’s weekly presentations.

I love Recurse a lot, and being in batch again has motivated me to really up my output - this is my 4th game in the last ~40 days! If you liked this project I think that you should consider applying.

Thanks for reading - I’ll be back with more nonsense soon :)