How to Build a Multiplayer Game Almost Without a Server (With Yjs + WebRTC)

If you've ever dreamed of making a multiplayer game that runs without needing a backend, this post is for you. We'll explore how to use Yjs and WebRTC to synchronize game state between players — peer to peer, with almost zero infrastructure.
🧠 What Does "Almost Without a Server" Mean?
WebRTC enables direct communication between browsers. However, to establish that connection, the players must first exchange network information (called signaling), usually through a small WebSocket server. The good news? That’s all the server does. No database, no game logic, just a minimal relay.
🔧 Tools Used
- Yjs – CRDT-based shared state
- y-webrtc – Yjs provider over WebRTC
- A tiny WebSocket server (or use a public one for testing)
🚀 How It Works
Instead of sending each movement over the network, players synchronize their state using a shared CRDT document. Every player holds the entire state of the game (e.g., character positions), and changes are automatically merged and broadcast.
// Initialize Yjs doc and WebRTC provider
import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
const ydoc = new Y.Doc();
const provider = new WebrtcProvider('my-vtt-room', ydoc, {
signaling: ['wss://your-signaling-server.com'] // Use your own or public
});
// Shared map of characters
const characters = ydoc.getMap('characters');
// Move a character
function moveCharacter(id, q, r) {
characters.set(id, { q, r }); // Full object replacement = observable
}
// Listen for changes
characters.observe(event => {
event.changes.keys.forEach((change, id) => {
const updated = characters.get(id);
animateCharacterTo(id, updated.q, updated.r);
});
});
🔥 Why This Works
- Peer to peer: WebRTC connects browsers directly, so latency is low.
- No central game server: State is shared and conflict-free using Yjs.
- Simple to integrate: Works in plain HTML, WebView, or React.
- Offline-capable (optionally): Yjs can be extended with persistence.
🧪 Gotchas
When using Yjs, it's critical to update state using its API. For example:
// ❌ This won't trigger observers!
const token = characters.get('knight');
token.q = 5; // silent fail
// ✅ This triggers observers and syncs across peers
characters.set('knight', { q: 5, r: 3 });
📦 What About the Signaling Server?
You can use Yjs’s public servers, but for production, hosting a tiny signaling server is easy. Here’s one in Node.js using ws
:
// signaling-server.js
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 4444 });
wss.on('connection', ws => {
ws.on('message', msg => {
wss.clients.forEach(client => {
if (client !== ws && client.readyState === ws.OPEN) {
client.send(msg);
}
});
});
});
✅ TL;DR
- You can build multiplayer without a real server using WebRTC and Yjs.
- You just need a signaling server for the handshake (no game logic, no database).
- Use
Y.Map.set
instead of mutating objects directly to trigger syncs and animations.