A YSWS from Hack Club Run by Arcade W. #in-synch on Slack
Ship one thing two people use.

In Synch

You ship an app two people can use at the same time.
We ship you a server( ESP-32 with a nice case ) running your app, and stickers!

Who
Anyone 18 or under
Runs
July 28th → August 24th 2026
Effort
5 hours, tracked
Library
AutomergeKISS

Already built it? Repo, README, five hours —

Submit →

The whole idea

Multiplayer is the part of software everybody assumes is hard. Accounts, a database, a server, websockets, and a week of your time before two things can agree on anything! AutomergeKISS deletes all of that. You get one shared object called a Space. You change it, and everyone else sees the change. They change it, and you see it. It even works offline and catches up later.

Your tab — offline in a tunnel

  • tasks.push("book the room")
  • tasks[0].done = true

Their tab — across the world

  • tasks.push("bring cables")
  • tasks.push("print signs")

Both, once you reconnect

  1. book the room  [done]
  2. bring cables
  3. print signs

No git merge conflicts, no lost work. That is what a well-designed CRDT buys you, and AutomergeKISS is a real Automerge document underneath, so you get it for free, without having to read a paper about it first.

What counts as a ship?

Tick these off as you go, this browser will remember.

Tutorial

This will take you from an empty folder to two browser windows editing the same list. If you would rather read code than prose, jump to step 9, which is the entire program in one file. Don't just submit that, I'd be annoyed.

  1. 1. Get the library
  2. 2. Serve it over http
  3. 3. Open a Space
  4. 4. Render on change
  5. 5. Write with update()
  6. 6. Test it: two tabs
  7. 7. Remember the Space
  8. 8. Show who is online
  9. 9. The whole thing
  10. 10. Ship it

1

Get the library

The fastest path has no build tools at all. Make a folder, and drop one file in it: the pre-built bundle, which has Automerge and its WASM inlined, so it is entirely self-contained.

terminal1 file, 1 folder

mkdir in-synch-demo && cd in-synch-demo

curl -O https://raw.githubusercontent.com/l3gacyb3ta/automergekiss/main/dist/automerge-kiss.bundle.js

Want to see the finished idea first? Clone the repo and double-click demo-standalone.html — it is a working collaborative task board with live presence, inlined into one file, no install and no server.

2

Serve it over http

Browsers refuse to load module scripts from file://, so opening your own index.html by double-clicking will fail. Run any static server from the folder instead.

terminalpick one

npx serve .            # node
python3 -m http.server # python

Note — The demo-standalone.html in the repo dodges this by inlining every script into the HTML itself. Worth doing at the end, when you want your project to be one double-clickable file.

3

Open a Space

One function, one await. If there is a Space code in the page URL you join that Space; otherwise a new one is created from your starter data.

index.htmltype="module"

<script type="module">
  import { openSpace } from "./automerge-kiss.bundle.js";

  const space = await openSpace({ starter: { items: [] } });

  console.log(space.data);      // { items: [] }
  console.log(space.shareUrl);  // send this to a friend
</script>

space.data is the current value — read it whenever you like. space.shareUrl is a link straight to this Space; space.code is the bare code (it looks like automerge:4kP...) if you would rather share that.

4

Render on change

onChange fires whenever the data changes — from you or from anyone else — and it also fires once immediately. That means one function can be your entire render path: you never write separate code for "draw the first time" and "draw again."

index.htmlthe render loop

const list = document.getElementById("items");

space.onChange((data) => {
  list.replaceChildren(...data.items.map((item) => {
    const li = document.createElement("li");
    li.textContent = item.text;
    return li;
  }));
});

It returns an unsubscribe function, so const stop = space.onChange(...) then stop() if you ever need to detach.

5

Write with update()

Never assign to space.data directly — that change would be invisible to everyone else. Pass a function to update() and mutate the draft however you like. Saving and syncing happen for you.

index.htmlwrites

space.update((data) => {
  data.items.push({ text: "buy snacks", done: false });
  data.items[0].done = true;
});

Push, splice, reassign a field, nest objects — ordinary JavaScript. Your onChange handler runs straight afterwards, so the screen updates without you calling render yourself.

6

Test it: two tabs

This is the moment the project becomes real. Open your page, copy the shareUrl it printed, paste it into a second tab. Type in one. Watch the other. Then close one tab, add three things, and reopen it.

YOUR TAB space.update() YOUR OTHER TAB same device Broadcast Channel PUBLIC RELAY hackclub-orchard WebSocket THEIR DEVICE the internet EVERY TAB KEEPS ITS OWN COPY IN INDEXEDDB. Go offline, come back, try it out!
Two tabs on one machine sync over BroadcastChannel, instantly, with no internet at all. Crossing devices goes through the relay.

Careful — The default server is hosted by HQ, and is a public sandbox for learning. It is not private and it makes no promises. Do not put anything real in it. If you outgrow it, pass server: "wss://your.server" and you won't depend on us.

7

Remember the Space

Two different things are easy to confuse. A Space's contents are always saved in the browser, with no setup. But which Space to reopen is your choice: by default the code rides in the URL, so revisiting the same link rejoins — and someone who opens the bare page gets a fresh empty Space. Pass remember to fix that.

index.htmlpersistence

const space = await openSpace({
  starter: { items: [] },
  remember: "in-synch-demo",   // or `true` for a default slot
});

Then a "start over" button forgets the slot and drops the code from the URL:

index.htmlnew board

import { forgetSpace } from "./automerge-kiss.bundle.js";

forgetSpace("in-synch-demo");
location.href = location.pathname;

Use a different name per app so their memories stay separate. The other options worth knowing: code to join a specific Space, localOnly: true for this-device-only, useUrlHash: false to keep the code out of the URL, and joinTimeout (8000ms by default) for how long to wait before showing a friendly error.

8

Show who is online

Presence is the cheapest way to make a project feel alive: names, colours, cursors, "Alex is typing." It is not saved — it only pings the people currently connected, which is exactly what you want for something that stops being true the moment someone closes the tab.

index.htmlpresence

space.setPresence({ name: "Alex", color: "#ff2d00" });

space.onPeers((peers) => {
  // peers = [{ id, presence: { name, color } }, ...]
  document.getElementById("who").textContent =
    peers.map((p) => p.presence.name).join(", ");
});

Call setPresence again any time the value changes — on mousemove for live cursors, on keypress for a typing indicator. And call space.leave() when you are finished, to drop the connection and clear its timers.

9

The whole thing

A complete, real-time, offline-capable, multiplayer shared list with presence. Save it as index.html next to the bundle, serve the folder, open the share link in a second tab. That is the ship requirement met, in 60 lines.

index.htmlcomplete

<!DOCTYPE html>
<meta charset="utf-8">
<title>shared list</title>

<p id="who"></p>
<form id="add">
  <input id="text" placeholder="add an item" autocomplete="off">
  <button>add</button>
</form>
<ul id="items"></ul>
<p>share: <a id="share"></a></p>

<script type="module">
  import { openSpace } from "./automerge-kiss.bundle.js";

  const space = await openSpace({
    starter: { items: [] },
    remember: "shared-list",
  });

  const share = document.getElementById("share");
  share.href = space.shareUrl;
  share.textContent = space.shareUrl;

  // one render path — used for the first paint and every update after
  space.onChange((data) => {
    document.getElementById("items").replaceChildren(
      ...data.items.map((item, i) => {
        const li = document.createElement("li");
        const box = document.createElement("input");
        box.type = "checkbox";
        box.checked = item.done;
        box.onchange = () => space.update((d) => {
          d.items[i].done = box.checked;
        });
        li.append(box, document.createTextNode(" " + item.text));
        return li;
      })
    );
  });

  document.getElementById("add").onsubmit = (event) => {
    event.preventDefault();
    const input = document.getElementById("text");
    const text = input.value.trim();
    if (!text) return;
    space.update((d) => { d.items.push({ text, done: false }); });
    input.value = "";
  };

  // presence: not saved, just who is here right now
  const me = { name: "guest-" + Math.floor(Math.random() * 999) };
  space.setPresence(me);
  space.onPeers((peers) => {
    const names = [me, ...peers.map((p) => p.presence)]
      .map((p) => (p && p.name) || "anon");
    document.getElementById("who").textContent =
      names.length + " here: " + names.join(", ");
  });
</script>

Now break it on purpose, because that is the interesting part. Turn off your wifi, add three items, turn it back on. Nothing is lost, and neither is anything the other tab did while you were gone.

10

Ship it

Make it yours — the list is scaffolding, not the project. Then push the repo, write the README, record the GIF/pictures of two windows moving together, and submit. If you would rather use a bundler than a copied file, the library is a single TypeScript file: npm create vite@latest, npm install @automerge/vanillajs, drop src/automerge-kiss.ts in, import from it.

And when the simple API runs out: it re-exports Repo, the adapters, and isValidAutomergeUrl, so you can drop into full Automerge — lists, rich text, counters — without rewriting what already works.

If you are stuck for an idea

Shipped something?

Public repo, a README with the sync GIF, 5 hours on Hackatime, and a link two people can open at once. Then claim your server by submitting!

Submit

Reviewed by a human, usually within a week

FAQAsk the rest in #in-synch

Can I participate?

Anyone 18 or younger can participate!

Do I have to use AutomergeKISS?

Yes — that is the point of this one. Your project has to open a Space and sync real state through it. You are welcome to eject into full Automerge on top of that; the library re-exports everything you need.

Do I need to understand CRDTs?

No. That is the entire reason this library exists. If you finish and find yourself curious about why it never loses an edit, the Automerge docs are excellent.

Do I need a server?

No. Syncing uses a public sandbox server by default, and two tabs on one machine sync with no internet at all. If you want your own, set server to your WebSocket URL — nothing else in your code changes.

What are the project requirements?

Your project should:

  • sync real state through an AutomergeKISS Space
  • be usable by two people at the same time
  • be open source
  • have a README.md, with a GIF or screenshots of two windows in sync
  • work out of the box (no compiling locally!)
Does a two-player game count?

Absolutely, as long as the game state lives in a Space and both players see each other's moves. Turn-based is fine.

How do I track time?

Use Hackatime with any text editor of your choice.

Can I submit my project to another Hack Club program?

Double-dipping isn't allowed, sorry!

When does In Synch end?

In Synch runs from July 28th to August 24th 2026.

I have more questions!

Ask a question in #in-synch, or send a DM to either @Arcade W. on Hack Club Slack.