CrescentHandbook
Multiplayer card
Cards

The Multiplayer card

Make something two (or more!) people can use together at the same time: a game, a shared whiteboard, a chat, a co-op puzzle…

Make something two (or more!) people can use together, at the same time. A game, a shared whiteboard, a chat, a co-op puzzle… anything where what I do shows up on your screen.

What counts

Two or more people using the same thing at the same time, where what one of them does affects the others. A good test: open it in two windows, do something in one, and watch it show up in the other without reloading.

  • Real-time things count, obviously! Games, shared canvases, cursors flying around a page, a music jam…
  • Turn-based counts too, as long as it’s live. Chess over the network is great, as long as your opponent’s move pops up on its own and you aren’t refreshing the page to check.
  • Local multiplayer, like two players sharing one keyboard or two gamepads on one screen, counts as well. Networked is a lot more fun though (and it’s what this guide is about), so give it a go if you can!

A normal website that lots of people use separately doesn’t count. A blog with comments has plenty of users, but they’re never really together. The same goes for a leaderboard that only changes when you reload.

Libraries and services for the networking part (Socket.IO, PeerJS, Yjs, a game engine’s multiplayer system…) are totally fine. What people actually do together should be yours. And if you’d rather build it over SSH or inside a Discord bot, the SSH guide and the Bot guide cover the setup for those.

Getting started, step by step

Here’s the smallest real multiplayer thing: a page where you can see everyone else’s mouse as a dot, live. It takes about fifteen minutes, and everything after that is up to you. You’ll need Node.js.

  1. Make a project. In a new folder, run:

    npm init -y
    npm install ws
    

    ws is a WebSocket library. A WebSocket is a connection between the browser and your server that stays open, so either side can send a message whenever it likes. With normal HTTP, the browser always has to ask first.

  2. Write the server. Save this as server.js. It serves your page, and whenever someone sends a message, it passes it on to everyone else:

    const http = require("http");
    const fs = require("fs");
    const { WebSocketServer, WebSocket } = require("ws");
    
    const server = http.createServer((req, res) => {
      res.writeHead(200, { "Content-Type": "text/html" });
      fs.createReadStream(__dirname + "/index.html").pipe(res);
    });
    
    const wss = new WebSocketServer({ server });
    
    wss.on("connection", (socket) => {
      socket.on("message", (data) => {
        for (const client of wss.clients) {
          if (client !== socket && client.readyState === WebSocket.OPEN) {
            client.send(data.toString());
          }
        }
      });
    });
    
    server.listen(process.env.PORT || 3000);
    

    That data.toString() matters! ws hands you each message as raw bytes, and if you pass those along as they are, the browser gets a binary Blob instead of text, and JSON.parse falls over.

  3. Write the page. Save this as index.html, right next to it. It sends your mouse position every time you move, and draws a dot wherever everyone else’s is:

    <!doctype html>
    <canvas width="800" height="600" style="border: 1px solid"></canvas>
    <script>
      const canvas = document.querySelector("canvas");
      const ctx = canvas.getContext("2d");
      const me = Math.random().toString(36).slice(2);
      const others = {};
      const socket = new WebSocket(location.origin.replace("http", "ws"));
    
      canvas.addEventListener("pointermove", (e) => {
        if (socket.readyState !== WebSocket.OPEN) return;
        socket.send(JSON.stringify({ id: me, x: e.offsetX, y: e.offsetY }));
      });
    
      socket.addEventListener("message", (e) => {
        const { id, x, y } = JSON.parse(e.data);
        others[id] = { x, y };
      });
    
      function draw() {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        for (const { x, y } of Object.values(others)) {
          ctx.beginPath();
          ctx.arc(x, y, 10, 0, Math.PI * 2);
          ctx.fill();
        }
        requestAnimationFrame(draw);
      }
      draw();
    </script>
    

    location.origin.replace("http", "ws") turns http:// into ws:// and https:// into wss://, so the same page works on your laptop and once it’s online.

  4. Run it. Start it with node server.js, then open http://localhost:3000 in two windows side by side. Move your mouse in one, and a dot follows along in the other. That’s multiplayer!! I promise the first time it works feels a little bit magical.

Using Bun? It has WebSockets built in, so you can skip ws. Bun.serve takes a websocket handler, and ws.subscribe() with ws.publish() does the passing-along for you. The Bun docs have an example.

Now make it yours

That’s where the tutorial stops, on purpose! A page full of dots isn’t a project yet, and we’d love it if nobody ships the same thing this week. You’ll run into a few problems straight away:

  • Close a tab, and its dot stays there forever. The server knows when someone leaves (socket.on("close", ...)), so it could tell everyone else.
  • Everyone’s a black dot. Who’s who?
  • Nothing is remembered. Someone who joins late sees an empty canvas until everyone else moves.

Solving those is already half a project. Then, some directions you could take it (or ignore completely):

  • Turn the dots into paint, and you’ve got a shared canvas. Maybe everyone only gets a little ink per minute…
  • Turn them into players. Tag, a co-op platformer, a party game where everyone answers on their phone and a big screen shows the results.
  • A co-op puzzle where each person only sees part of the clues, so you have to talk to each other.
  • A board or card game you grew up with, playable with a friend across town.
  • Something weirder: a piano everyone plays at once, a pet the whole internet raises together, a page where all the cursors are fish.

Going further

  • Rooms. Right now, everyone on your server is in one big room. Give each game a code (or a link like /?room=fox42) and only pass messages around inside it.
  • Who’s in charge? The starter believes anything anyone sends, so a player could just announce they’re at the finish line. For games, make the server authoritative: clients send which keys they pressed, and the server runs the game and tells everyone where things ended up.
  • Lag. Messages take time to travel, so other players will jump around. Real games hide it with interpolation (drawing others slightly in the past and sliding smoothly between updates) and client-side prediction (moving yourself right away, then fixing it if the server disagrees). Gabriel Gambetta’s Fast-Paced Multiplayer explains both really well, with demos you can play with, and Gaffer On Games goes deeper.
  • Peer to peer. With WebRTC, browsers talk to each other directly, which is great for voice and video, or for games without a server in the middle. Something still has to introduce the two browsers to each other at the start, and PeerJS handles that part for you.
  • Shared documents. When two people edit the same text at once, their edits will eventually collide. CRDTs are data structures built to merge them without anyone losing work, and Yjs is a great one, with ready-made bindings for lots of text editors.
  • Bigger toolkits. Socket.IO adds rooms and reconnecting on top of WebSockets, Colyseus is made for multiplayer games, and Godot has multiplayer built in.

Putting it online

Your server has to keep running and stay connected to everyone, so not every host works here. GitHub Pages only serves files, and Vercel’s functions can’t hold a WebSocket open. These can:

  • Nest, Hack Club’s free Linux server for teenagers. It’s a real computer that’s always on, so your server just keeps running. Ask in #nest if you get stuck!
  • Render supports WebSockets on its free web services. Free services fall asleep after 15 minutes without visitors and take about a minute to wake up, so mention that in your README, or we might think it’s broken.
  • Railway works too, but once the trial runs out, the free plan is only $1 of credit a month.

The starter already listens on process.env.PORT, which is how most hosts tell your app which port to use.

Before you ship

There’s a few things every project needs before we can approve it:

  • It works. It doesn’t have to be finished, but the main thing it does should actually work.
  • All of your code is public, on GitHub or somewhere like it.
  • Anyone can try it, even someone who doesn’t code, in under two minutes of setup.
  • Someone else could rebuild it from your repo and README.

Multiplayer adds one more: your project is usually reviewed by just one person, so we have to be able to try it on our own. Make sure it works when we open it in two tabs or two windows side by side, and say exactly how in your README (“open the link, click New game, then paste the invite link into a second tab”). A few things that make that go smoothly:

  • Don’t make us find a friend. If your game needs four players, let it start with two, or fill the empty seats with bots.
  • Two tabs should be two players. If you keep a player’s identity in localStorage, both tabs will think they’re the same person. sessionStorage is separate for every tab (as long as you open a new tab rather than duplicating one).
  • Keep your server up for the whole review. If the page can’t connect, there’s nothing for us to try!
  • Don’t put it behind a login. Let people pick a name and jump in.

What that looks like depends on where your project lives:

  • On the web: a public link that stays up, with the server running somewhere like the hosts above. Please don’t use Streamlit.
  • As a desktop app or game: a GitHub release with something people can download and run (an .exe, a .dmg, an .AppImage…), plus either a server that’s already running or a way to host a game from inside the app. Make sure two copies can run on one computer! The source code on its own isn’t enough.
  • Local multiplayer: tell us each player’s controls, and make sure everyone fits on one keyboard (or say which controllers we need).

A video of you and your friends playing is an awesome way to show it off, but it can’t be your demo. Put videos on YouTube or Vimeo, or upload an .mp4, rather than Google Drive.

Shipping one

  • Demo: a link where we can jump straight in. An invite link we can paste into a second tab makes testing it super easy!
  • Screenshot: two windows side by side in the middle of a game (or a doodle), so it’s obvious more than one person is in there.
  • README: how to try it alone with two tabs, what each player does, and how the multiplayer part works: what the server does, what gets sent, and how you dealt with lag. A video of real people using it together is always great to include.
Last updated 28 Oct 2026