Recently, a gif ** appeared on social networks showing a stunning work of art made by Bjorn Staal.
I wanted to recreate it, but lacked 3D skills in spheres, particles, and physics, and my goal was to understand how to make one window react to the position of another.
Essentially, sharing state between multiple windows, which I found to be one of the coolest aspects of the bjorn project!
Since I couldn't find a good article or tutorial on the topic, I decided to share my findings with you.
Let's try to create a simplified proof of concept (PoC) based on BJORN's work!
The first thing I did was make a list of all the ways I knew to share information between multiple clients:
Obviously, having a server (with polling or websocket) simplifies the problem. However, this is not possible since bjorn achieved his results without using a server.
Local storage is essentially a browser key-value store that is typically used to save information between browser sessions. While typically used to store authentication tokens or redirect URLs, it can store anything that is serializable. You can learn more here.
I've recently found some interesting local storage APIs, including the storage event that fires whenever the local storage is changed by another session of the same **.
We can take advantage of this by storing the state of each window in local storage. Whenever one window changes its state, the other windows are updated by storing events.
This was my initial thought, and this seems to be the solution chosen by bjorn because he shares his localstorage manager** here and an example of using it with threejs.
But when I found out that there was ** to solve this problem, I wanted to see if there was another way ......Spoiler alert: Yes, there is!
Behind this flashy term is a fascinating concept – the concept of webworkers.
In simple terms, a worker thread is essentially a second script that runs on another thread. While they don't have access to the DOM (because they exist outside of the HTML document), they can still communicate with your main script.
They are primarily used to offload main scripts by handling background jobs, such as prefetching information or handling less critical tasks, such as flow logs and polling.
Shared workers are a special type of webworkers that can communicate with multiple instances of the same script, which makes them interesting for our use case!Well, let's jump right in!
As mentioned earlier, workers are "second scripts" with their own entry points. Depending on your setup (TypeScript, Programs, Dev Server), you may need to adjust tsconfig, add directives, or use a specific import syntax.
I can't cover all the possible ways to use web workers, but you can find information on mdn or the internet.
I'd be happy to write a prequel to this article if needed, detailing all the ways to set them up!
In my case, I use vite and typescript, so I need a workerts file and install it @types sharedworker as the development dependency. We can use the following syntax to create a connection in the main script:
new sharedworker(new url("worker.ts", import.meta.url));
Basically, we need to:
Identifying each window keeps track of all window states, and once a window changes state, it will be very simple to remind other windows to redraw our state:
type windowstate = ;
Of course, the most important piece of information is the windowscreenxthemwindow.Screeny tells us where the window is relative to the top left corner of the monitor.
We will have two types of messages:
Each window, whenever its state changes, publishes a WindowStateChangedMessage with its new state. The worker will send an update to all other windows to alert them that one of the windows has changed. The worker will send a syncmessage with all the window status. We can start with an average worker who looks a bit like this:
// worker.ts let windows: [= onconnect = ()=> ;
Our basic connection to the sharedworker is shown below. I have some basic functions that generate the id, and calculate the current window state, and I also have some input into a message type that we can use called workermessage:
// main.ts import from "./types"; import from "./windowstate"; const sharedworker = new sharedworker(new url("worker.ts", import.meta.url));let currentwindow = getcurrentwindowstate();let id = generateid();
As soon as we launch the application, we should alert the staff that there is a new window, so we immediately send a message:
// main.ts sharedworker.port.postmessage(, satisfies workermessage);
We can listen for this message on the working side and change the onmessage accordingly. Basically, once the worker gets the WindowStateChanged message, either it's a new window and we attach it to the state, or it's an old window that has changed. Then we should remind everyone that the status has changed:
// worker.ts port.onmessage = function (event: messageevent) = msg.payload; const oldwindowindex = windows.findindex((w) => w.id === id); if (oldwindowindex !== -1) else );windows.foreach((w) => // send sync here );break; }
In order to send the sync, I actually need some tricks, because the "port" attribute can't be serialized, so I stringize it and parse it back. Because I'm lazy, I don't just map windows to more serializable arrays:
w.port.postmessage(, satisfies workermessage);
Now it's time to draw something!
Of course, we don't do complex 3D spheres: we'll just draw a circle in the center of each window and a line between the spheres!
I'm going to draw with the basic 2D context of HTML Canvas, but you can use whatever you want. Draw a circle, it's very simple:
const drawcentercircle = (ctx: canvasrenderingcontext2d, center: coordinates) => = center; ctx.strokestyle = "#eeeeee"; ctx.linewidth = 10; ctx.beginpath();ctx.arc(x, y, 100, 0, math.pi * 2, false); ctx.stroke();ctx.closepath();
In order to draw the line, we need to do some math (I promise, it's not a lot), converting the relative position of the center of the other window to the coordinates of the current window.
Basically, we are changing the base. I do it with a little math. First, we'll change the base to have coordinates on the monitor, offset by the current window screenx screeny.
const basechange = (:=> ;const currentwindowcoordinate = ; return currentwindowcoordinate; }
As you know, now that we have two points in the same relative coordinate system, we can now draw the line!
const drawconnectingline = (:=> ;const targetwindowoffset: coordinates = ; const origin = getwindowcenter(hostwindow); const target = getwindowcenter(targetwindow); const targetwithbasechange = basechange();ctx.strokestyle = "#ff0000"; ctx.linecap = "round"; ctx.beginpath();ctx.moveto(origin.x, origin.y); ctx.lineto(targetwithbasechange.x, targetwithbasechange.y); ctx.stroke();ctx.closepath();
Now, we just need to react to the state change.
// main.ts sharedworker.port.onmessage = (event: messageevent) => )=> )
As a final step, we just need to periodically check if the window changes and send a message if it does.
setinterval(()=> )satisfies workermessage); currentwindow = newwindow; }100);
You can find the full ** in this repository. Actually, I've done a lot of experiments with it to make it more abstract, but the gist is the same.
If you run it on multiple windows, hopefully you get the same result as this!