Introducing KENOBI — Serverless Multiplayer Gaming on Bitcoin
KENOBI — Kryptographic Ephemeral Network for On-chain Bitcoin-Inscribed gaming.
A complete, copy-paste-friendly reference for developers who want to ship real-time multiplayer browser games without running a game server, without accounts, and (optionally) straight from a Bitcoin Ordinals inscription.This document describes the exact stack powering our browser-based multiplayer games on Bitcoin. Every code sample is production-tested — copy it into your own HTML inscription and swap in your game-specific strings.
What KENOBI gives you in one line: Nostr relays handle matchmaking and the WebRTC handshake, peer-to-peer DataChannels carry the gameplay, and the entire game ships as a single HTML file inscribed on Bitcoin — no servers, no accounts, no hosting bill.
Two delivery methods — pick one:
- Method A (the focus of §1–§14 below) — Public Nostr relays for signalling + an external TURN provider for NAT traversal. Fully serverless, free to run, best for casual / co-op titles.
- Method B (covered in §15) — One self-hosted WebRTC relay on a small VPS that handles both signalling and TURN fallback. Higher reliability, required for competitive / PvP games where every player must connect. See §15 for the full write-up.
Both ship the game HTML as a Bitcoin inscription; they differ only in where the signalling and NAT-traversal plumbing lives.
| Letter | Stands for | What it means in the stack |
|---|---|---|
| Kryptographic | Ephemeral secp256k1 keypairs generated per session sign every Nostr event. No account, no wallet, no leaked identity. | |
| Ephemeral | Every session is disposable — keys, rooms, and game state all dissolve when the host closes the tab. | |
| Network | Three public Nostr relays form the decentralized signaling layer (nos.lol, damus, wellorder). | |
| On-chain | Game logic and assets are inscribed on Bitcoin — permanently resolvable by any Ordinals viewer. | |
| Bitcoin-Inscribed | The game HTML + 3D models + audio live as Bitcoin Ordinals inscriptions, served directly from any Ord node. | |
| gaming | Real-time multiplayer, host-authoritative, 10+ concurrent players per room. |
0. TL;DR — What's Revolutionary About KENOBI
| Traditional multiplayer | This stack |
|---|---|
| Central game server (Node/Go/C#) on EC2/Railway | Zero game server. The host player IS the server. |
| Matchmaking service + account system | Zero accounts. Random Nostr keypair per session. |
| WebSocket signaling server (e.g. socket.io, ws) | Zero signaling server. Public Nostr relays shuttle SDP. |
| Monthly hosting bill scales with CCU | Bandwidth = players' upload. Your cost: $0. |
| Must run HTTPS, certs, CORS, rate limits | Static HTML file. Serve from anywhere — S3, GitHub Pages, a Bitcoin inscription. |
The only server-side piece you still need is a TURN server — and only because ~15–25 % of players are behind symmetric NATs where pure WebRTC peer-to-peer fails. We'll cover both free and self-hosted options below.
KENOBI architecture in one sentence: The host's browser opens WebRTC DataChannels directly to each guest's browser; the SDP offer/answer handshake is exchanged via signed Nostr events on three public relays; once connected, all game traffic is P2P and the host is the authoritative game simulator.
1. The Big Picture
txt
┌──────────────┐ ┌──────────────┐
│ HOST │ │ GUEST │
│ (browser) │ │ (browser) │
└──────┬───────┘ └──────┬───────┘
│ │
│ 1. publish room heartbeat (kind 30311) │
│ ───────────────────┐ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ NOSTR RELAYS │ │
│ │ wss://nos.lol │ ◄──────────────┤ 2. subscribe to rooms
│ │ wss://damus │ │ (kind 30311 filter)
│ │ wss://wellorder │ │
│ └──────────────────┘ │
│ 4. SDP OFFER │ 3. JOIN REQ │
│ (kind 1314) │ (kind 1312) │
│◄──────────────────────┼──────────────────────────►│
│ │ │
│ 5. SDP ANSWER │ │
│ (kind 1315) │ │
│◄──────────────────────┴──────────────────────────►│
│ │
│ 6. STUN / TURN / ICE candidate exchange │
│◄──────────────────────────────────────────────────►│
│ (via stun.l.google.com + TURN) │
│ │
│ 7. WebRTC DataChannel OPEN — P2P from here on │
│◄══════════════════════════════════════════════════►│
│ │
│ position, input, chat, zombie kills, etc. │
│ (JSON messages over the channel, ~10 Hz) │
│ │
2. Component Breakdown
2.1 Nostr Signaling Layer
Nostr (Notes and Other Stuff Transmitted by Relays) is used purely as a public bulletin board for SDP handshakes. We do not use Nostr for any gameplay, identity, or chat. Once WebRTC connects, Nostr is irrelevant for that session.
Why Nostr instead of a WebSocket signaling server?
- No server to host, deploy, or pay for.
- Resistant to takedown — three independent public relays.
- Signed events prove authorship (a guest can verify the host's offer is from the advertised room).
- Ephemeral keys = no account system.
Public relays used:
js
var NOSTR_RELAYS = [
'wss://nos.lol',
'wss://nostr-pub.wellorder.net',
'wss://relay.damus.io'
];
All three are free, globally reachable, and have worked reliably in production. You can add more for redundancy.
Event kinds:
| Kind | Direction | Purpose | NIP |
|---|---|---|---|
| 30311 | Host → world | Room heartbeat (replaceable, re-published every 30 s) | NIP-53 Live Events |
| 1312 | Guest → host | "I want to join your room" | Custom |
| 1314 | Host → guest | Compressed SDP offer | Custom |
| 1315 | Guest → host | Compressed SDP answer | Custom |
Pick your own kind numbers in the 1000–9999 range for a new game — don't collide with NIP-defined kinds.
Game namespace tag:
js
var NOSTR_GAME_TAG = 'your-game-lobby-v1';
Every room event carries so guests filter only rooms for your game. Change this string for your game. Bump the when you make a breaking protocol change; old clients on will silently stop seeing new rooms.
txt
['t', NOSTR_GAME_TAG]txt
-v1txt
-v1txt
-v22.2 WebRTC Transport Layer
Once both peers have each other's SDP, the standard browser takes over. We use:
txt
RTCPeerConnection- One per peer namedtxt
RTCDataChanneltxt'game' - — unordered delivery, because we'd rather drop a stale position update than pause the channel waiting to retransmittxt
{ordered: false} - Star topology — guests connect only to the host; the host relays broadcasts
ICE server config:
js
// Plug in TURN credentials from whatever provider you use. Rotate across
// multiple accounts if you expect heavy traffic.
var TURN_CREDS = [
{ username: 'YOUR_TURN_USERNAME', credential: 'YOUR_TURN_CREDENTIAL' }
];
function buildIceServers() {
// Chrome gathers ICE candidates from every TURN server listed per PC, so
// stacking multiple credentials in one config multiplies per-peer quota cost.
// Rotate instead — pick one at random per new peer.
var c = TURN_CREDS[Math.floor(Math.random() * TURN_CREDS.length)];
return [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'turn:YOUR_TURN_HOST:3478', username: c.username, credential: c.credential },
{ urls: 'turn:YOUR_TURN_HOST:3478?transport=tcp', username: c.username, credential: c.credential }
];
}
⚠️ The comment on lines 505–506 is load-bearing. Do not stack all TURN credentials into one config — Chrome consumes quota per-TURN-server-per-peer. Rotate.
2.3 TURN Server (The Only Server-Side Piece)
STUN servers just tell each peer its public IP/port. TURN servers are full-traffic relays used as a last resort when both peers are behind hostile NATs. You'll want a TURN server — roughly 15–25 % of real-world peer pairs end up relayed through one.
Plug in whatever TURN provider or setup works for your game. Drop the values into and point at your host. The rest of the KENOBI stack doesn't care where TURN comes from.
txt
{ username, credential }txt
TURN_CREDStxt
turn:...3. The Nostr Client — Raw WebSocket, No Library
We deliberately do not use or from at runtime. Reasons:
txt
SimplePooltxt
RelayPooltxt
nostr-tools- Smaller bundle (27 KB vs 55+ KB)
- Full control over reconnect behaviour
- Ordinals inscriptions cap useful payload size, so every KB counts
We only use for signing — key generation, pubkey derivation, event finalization (schnorr signature). The relay socket, subscription, and publish are all hand-rolled.
txt
nostr-tools3.1 Bundling just the signing helpers (file: txtwebrtc-poc/bundle-nostr-minimal.mjs
)
txt
webrtc-poc/bundle-nostr-minimal.mjsjs
import { build } from 'esbuild';
import fs from 'fs';
const entry = `
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
window.NostrSign = { generateSecretKey, getPublicKey, finalizeEvent };
`;
fs.writeFileSync('_nostr-min-entry.js', entry);
await build({
entryPoints: ['_nostr-min-entry.js'],
bundle: true, minify: true,
format: 'iife', platform: 'browser', target: 'es2020',
outfile: 'nostr-min-bundle.js'
});
fs.unlinkSync('_nostr-min-entry.js');
Run it:
bash
npm install nostr-tools esbuild
node bundle-nostr-minimal.mjs
# → nostr-min-bundle.js (~27 KB minified)
Then inline the output directly into your game HTML inside . This gives you a fully self-contained HTML file with no external JS dependency.
txt
<script id="nostrBundle">...</script>3.2 The raw-WebSocket Nostr client
js
function nostrConnect(url) {
var ws; try { ws = new WebSocket(url); } catch (e) { return null; }
var subs = {}, pubResolvers = {}, openQueue = [], isOpen = false;
ws.onopen = function () {
isOpen = true;
openQueue.forEach(function (f) { try { ws.send(f); } catch (e) {} });
openQueue.length = 0;
};
ws.onmessage = function (ev) {
var m; try { m = JSON.parse(ev.data); } catch (e) { return; }
var t = m[0];
if (t === 'EVENT') { var s = subs[m[1]]; if (s && s.onevent) s.onevent(m[2], url); }
else if (t === 'EOSE') { var s2 = subs[m[1]]; if (s2 && s2.oneose) s2.oneose(url); }
else if (t === 'OK') { var r = pubResolvers[m[1]]; if (r) { clearTimeout(r.timer); delete pubResolvers[m[1]]; m[2] ? r.resolve() : r.reject(new Error(m[3] || 'rejected')); } }
};
function send(f) { if (isOpen) { try { ws.send(f); } catch (e) {} } else openQueue.push(f); }
return {
url: url,
publish: function (evt) {
return new Promise(function (res, rej) {
var timer = setTimeout(function () { if (pubResolvers[evt.id]) { delete pubResolvers[evt.id]; rej(new Error('timeout')); } }, 8000);
pubResolvers[evt.id] = { resolve: res, reject: rej, timer: timer };
send(JSON.stringify(['EVENT', evt]));
});
},
subscribe: function (filter, handlers) {
var subId = 's' + Math.random().toString(36).slice(2, 10);
subs[subId] = handlers;
send(JSON.stringify(['REQ', subId, filter]));
return { close: function () { send(JSON.stringify(['CLOSE', subId])); delete subs[subId]; } };
},
close: function () { try { ws.close(); } catch (e) {} }
};
}
This is the entire Nostr relay protocol you need. Fan out to all three relays:
js
function nostrPublishAll(evt) {
var pubs = nostrConns.map(function (r) {
return r ? r.publish(evt).then(function () { return true; }).catch(function () { return false; }) : Promise.resolve(false);
});
return Promise.all(pubs).then(function (rs) { return rs.filter(Boolean).length; });
}
function nostrSubscribeAll(filter, onevent, oneose) {
var seen = {};
var subs = nostrConns.map(function (r) {
if (!r) return null;
return r.subscribe(filter, {
onevent: function (evt, url) { if (seen[evt.id]) return; seen[evt.id] = true; onevent(evt, url); },
oneose: oneose
});
});
return { close: function () { subs.forEach(function (s) { s && s.close(); }); } };
}
The deduping is important — the same event arrives from all three relays and you only want to process it once.
txt
seen[evt.id]3.3 Ephemeral keypair per session
js
function nostrInit() {
if (!window.NostrSign) return false;
if (nostrSk) return true;
nostrSk = window.NostrSign.generateSecretKey(); // fresh secp256k1 key
nostrPk = window.NostrSign.getPublicKey(nostrSk);
nostrConns = NOSTR_RELAYS.map(function (u) { return nostrConnect(u); });
return true;
}
No login, no wallet popup, no localStorage. Every page refresh = new identity. Your game's player name is separate (just a string the user types in).
4. The Full Signalling Flow, Step by Step
4.1 Host side — publish room + await guests
Publish room heartbeat every 30 s:
js
function nostrStartHosting() {
if (!NOSTR_ENABLED || !nostrInit()) return;
function pubRoom() {
var evt = nostrMakeEvent(NOSTR_KIND_ROOM, JSON.stringify({
name: myName || 'Host',
game: 'your-game',
ts: Date.now()
}), [
['d', 'room'], // NIP-33 replaceable tag
['t', NOSTR_GAME_TAG], // game filter
['title', (myName || 'Host') + "'s room"],
['status', 'live']
]);
nostrPublishAll(evt);
}
pubRoom();
nostrPublishTimer = setInterval(pubRoom, 30000);
// Publish "ended" on tab close so guests' lobbies drop us immediately
window.addEventListener('beforeunload', function () {
var endEvt = nostrMakeEvent(NOSTR_KIND_ROOM, JSON.stringify({
name: myName, game: 'your-game', ts: Date.now()
}), [['d','room'], ['t', NOSTR_GAME_TAG], ['title', myName + "'s room"], ['status','ended']]);
nostrPublishAll(endEvt);
});
// Listen for join requests targeting us (p-tag = our pubkey)
nostrJoinSub = nostrSubscribeAll({
kinds: [NOSTR_KIND_JOIN_REQ],
'#p': [nostrPk],
since: Math.floor(Date.now()/1000) - 5
}, function (evt) {
if (nostrJoinRequests[evt.id]) return;
var body; try { body = JSON.parse(evt.content); } catch (e) { return; }
nostrJoinRequests[evt.id] = { guestPk: evt.pubkey, guestName: body.name, evt: evt };
nostrShowJoinPopup(evt.id); // shows accept/reject UI
});
// Listen for answers coming back
nostrAnswerSub = nostrSubscribeAll({
kinds: [NOSTR_KIND_ANSWER],
'#p': [nostrPk],
since: Math.floor(Date.now()/1000) - 5
}, function (evt) {
var body; try { body = JSON.parse(evt.content); } catch (e) { return; }
if (!body.peerId || !body.answerCode) return;
acceptAnswer(body.answerCode, body.peerId);
});
}
Create a fresh per guest and ship the offer:
txt
RTCPeerConnectionjs
function createInvite(nostrGuestPk) {
var peerId = 'peer_' + Date.now();
var pc = new RTCPeerConnection({ iceServers: buildIceServers() });
var dc = pc.createDataChannel('game', { ordered: false });
pc.oniceconnectionstatechange = function () {
if (pc.iceConnectionState === 'failed') pc.restartIce();
};
setupHostDC(peerId, dc, pc);
pc.createOffer()
.then(function (offer) { return pc.setLocalDescription(offer); })
.then(function () { return waitICE(pc); })
.then(function () {
var code = encode(pc.localDescription.sdp, 'offer', myName);
pendingInvites[peerId] = { pc: pc, dc: dc };
if (nostrGuestPk) {
nostrSendOfferToGuest(peerId, code, nostrGuestPk);
} else {
// Fallback: manual copy/paste flow — show code in UI
document.getElementById('offerCode').textContent = code;
}
});
}
4.2 Guest side — browse rooms + join
Subscribe to all live rooms:
js
function nostrStartBrowsing(onRoomsUpdate) {
if (!NOSTR_ENABLED || !nostrInit()) return;
nostrRooms = {};
nostrRoomSub = nostrSubscribeAll({
kinds: [NOSTR_KIND_ROOM],
'#t': [NOSTR_GAME_TAG],
since: Math.floor(Date.now()/1000) - 60 // last 60 s
}, function (evt) {
var body; try { body = JSON.parse(evt.content); } catch (e) { return; }
if (body.game !== 'your-game') return;
var status = (evt.tags.find(function (t) { return t[0] === 'status'; }) || [])[1];
if (status === 'ended') { delete nostrRooms[evt.pubkey]; onRoomsUpdate && onRoomsUpdate(); return; }
var existing = nostrRooms[evt.pubkey];
if (existing && evt.created_at <= existing.createdAt) return; // drop stale replay
nostrRooms[evt.pubkey] = { name: body.name, createdAt: evt.created_at, evt: evt };
onRoomsUpdate && onRoomsUpdate();
});
}
The lobby UI re-renders from on every update. Rooms older than 90 s are filtered out client-side — a host who crashes stops sending heartbeats and fades from all lobbies within 90 s without any explicit cleanup.
txt
nostrRoomsJoin a specific host:
js
function nostrJoinRoom(hostPk) {
nostrActiveHostPk = hostPk;
// Listen for an offer from this specific host, tagged to us
nostrOfferSub = nostrSubscribeAll({
kinds: [NOSTR_KIND_OFFER],
'#p': [nostrPk],
authors: [hostPk],
since: Math.floor(Date.now()/1000) - 5
}, function (evt) {
var body; try { body = JSON.parse(evt.content); } catch (e) { return; }
if (!body.offerCode || !body.peerId) return;
window._nostrPendingPeerId = body.peerId;
window._nostrPendingHostPk = hostPk;
// Reuse the manual-paste flow by populating the textarea
document.getElementById('peerCode').value = body.offerCode;
joinGame();
});
// Send join request
var req = nostrMakeEvent(NOSTR_KIND_JOIN_REQ,
JSON.stringify({ name: myName }),
[['p', hostPk]]);
nostrPublishAll(req);
}
Send SDP answer back:
js
function nostrSendAnswerToHost(answerCode) {
var evt = nostrMakeEvent(NOSTR_KIND_ANSWER, JSON.stringify({
peerId: window._nostrPendingPeerId,
answerCode: answerCode
}), [['p', window._nostrPendingHostPk]]);
nostrPublishAll(evt);
}
4.3 The txtpeerId
dance — why it matters
txt
peerIdWhen a host is popular, it may have 5 simultaneous (one per guest in handshake). Answers arrive asynchronously and must be matched to the correct — picking the wrong one causes DTLS to silently fail.
txt
pendingInvitestxt
RTCPeerConnectionjs
function acceptAnswer(answerCode, specificPeerId) {
var decoded = decode(answerCode);
var matched = null;
if (specificPeerId && pendingInvites[specificPeerId] &&
pendingInvites[specificPeerId].pc.signalingState !== 'closed') {
matched = specificPeerId; // Nostr path: exact match
} else {
for (var id in pendingInvites) { // Manual path: FIFO
if (pendingInvites[id].pc.signalingState !== 'closed') { matched = id; break; }
}
}
if (!matched) return;
var inv = pendingInvites[matched];
inv.pc.setRemoteDescription({ type: 'answer', sdp: decoded.sdp }).then(function () {
peers[matched] = { name: decoded.username, pc: inv.pc, dc: inv.dc };
delete pendingInvites[matched];
});
}
Always include the in your offer/answer JSON payloads.
txt
peerId5. SDP Compression — Why and How
Nostr events have practical size limits (most relays reject >64 KB; some are stricter). A raw WebRTC SDP offer with gathered ICE candidates is ~2–5 KB. Bigger than needed.
We extract the six fields that matter and pack them into a single comma-delimited string (~200–400 chars). The receiving peer reconstructs a minimal valid SDP from those fields.
Encode:
js
function encode(sdp, type, name) {
var p = extractParams(sdp); // pulls ufrag, pwd, fingerprint, ICE candidates
var candStr = p.cands.length ? p.cands.join('|') : '0.0.0.0:9';
return (type === 'offer' ? 'O' : 'A') + ',' + name + ',' + p.ufrag + ',' + p.pwd + ',' + p.fp + ',' + candStr;
}
Result format:
txt
O,PlayerName,ufrag,pwd,fingerprintHex,ip1:port1|ip2:port2|…Decode:
js
function decode(tok) {
var t = tok.trim().split(',');
if (t.length < 6) throw new Error('need 6 parts');
var type = t[0] === 'O' ? 'offer' : 'answer';
var name = t[1], ufrag = t[2], pwd = t[3], fpHex = t[4], candStr = t[5];
// reconstruct ice-ufrag, ice-pwd, fingerprint, candidate lines into a minimal SDP...
return {
type, username: name,
sdp: 'v=0\r\no=- ...\r\na=ice-ufrag:' + ufrag + '\r\na=ice-pwd:' + pwd + '\r\n' +
'a=fingerprint:sha-256 ' + fp + '\r\n' + candLines + 'a=end-of-candidates\r\n'
};
}
This compressed code is also what the user can manually copy-paste if Nostr is unavailable — same format, different transport. The fallback is why your UI should always expose a "paste invite code" textarea alongside the Nostr lobby.
6. ICE Gathering Timeout
Don't wait forever for — some browsers take 15+ seconds. Cap it:
txt
onicecandidate(null)js
// reference: your-game.html
function waitICE(pc) {
return new Promise(function (resolve) {
if (pc.iceGatheringState === 'complete') return resolve();
var done = false;
function fin() { if (!done) { done = true; resolve(); } }
pc.onicecandidate = function (e) { if (!e.candidate) fin(); };
pc.onicegatheringstatechange = function () { if (pc.iceGatheringState === 'complete') fin(); };
setTimeout(fin, 8000); // HARD cap — ship what we have
});
}
8 seconds is a sweet spot: enough for STUN + TURN gathering on slow networks, short enough that users don't assume the app froze.
7. Game State Protocol
7.1 Topology: Host-Authoritative Star
- All guests connect only to the host.
- The host is the single source of truth for every game entity (enemies, loot, scores).
- Guests send inputs (position, shoot-this-direction, use-item). Host validates and either rejects or applies + broadcasts.
- Peer disconnection is handled entirely by the host — guests never talk to each other directly.
Why star, not mesh? A 10-player mesh is 45 instances. A 10-player star is 9. Browsers slow to a crawl past ~20 peer connections. The tradeoff is host upload bandwidth — plan for ~3–8 KB/s up per guest.
txt
RTCPeerConnection7.2 Message format — all JSON over the DataChannel
Handshake on connect:
js
// Guest → Host
{ type: 'hello', name: 'Alice' }
// Host → Guest (reply on dc.onopen)
{ type: 'init',
yourColor: '#ff6a00',
hostName: 'Bob',
players: [{name:'Bob',color:'#00ff44'}, {name:'Carol',color:'#ff00ff'}],
buildings: [true, false, true], // any persistent world state
fireSaleUntil: 1713012345678 }
Position updates (10 Hz, sent on movement delta):
js
// Guest → Host
{ type:'pos', x:5.2, y:1.65, z:-3.1, ry:1.57, zone:'office', w:0 }
// Host broadcasts to everyone else
{ type:'pos', id:'peer_1713012345', name:'Alice', x:5.2, y:1.65, z:-3.1, ry:1.57, zone:'office', color:'#ff6a00', w:0 }
Broadcast helper on host:
js
function broadcastAsHost(msg, excludeId) {
var data = JSON.stringify(msg);
for (var id in peers) {
if (id !== excludeId && peers[id].dc && peers[id].dc.readyState === 'open') {
try { peers[id].dc.send(data); } catch (e) {}
}
}
}
Example: zombie hit (authoritative damage calc on host):
js
// Guest → Host
{ type:'hitZombie', id:'zombie_42', damage:25 }
// Host → all
{ type:'zombieDied', id:'zombie_42', points:100 }
// Host → the specific killer only
{ type:'youKilledZombie', points:100 }
Full handler:
js
case 'hitZombie':
if (zombies[m.id]) {
zombies[m.id].hp -= getInstaDamage(m.damage);
if (zombies[m.id].hp <= 0) {
var pts = Math.floor(100 * (1 + waveNumber * 0.1) * getPointsMult());
broadcastAsHost({ type:'zombieDied', id:m.id, points:pts }, null);
if (peers[peerId] && peers[peerId].dc && peers[peerId].dc.readyState === 'open') {
peers[peerId].dc.send(JSON.stringify({ type:'youKilledZombie', points:pts }));
}
removeZombie(m.id);
}
}
break;
7.3 Tick rate guidance
| Data kind | Rate | Notes |
|---|---|---|
| Player position | 10 Hz | Only on meaningful delta (>0.1 units or >0.05 rad) |
| Chat | event-driven | Cap message to 200 chars |
| Shooting / hits | event-driven | Send on fire, not per-frame |
| Enemy position | 10 Hz | Host → guests, cull entities >300 u away from each guest |
| Full state resync | every 2 s | Cheap insurance against lost updates |
Remote players should interpolate between received positions (~100 ms buffer) so movement looks smooth even at 10 Hz update rate.
8. Reconnection & Edge Cases
js
pc.oniceconnectionstatechange = function () {
if (pc.iceConnectionState === 'failed') pc.restartIce();
};
On , wait 5 s before calling — transient mobile-network drops usually recover on their own.
txt
disconnectedtxt
restartIce()Other hardening:
- Host broadcasts whentxt
{type:'left', id}fires so every guest's player list updates.txtdc.onclose - If the host disconnects, guests should surface a "Host left — return to lobby" modal and re-browse Nostr for a new room.
- Guests must accept that a crashing host = end of session. There is no failover. Resilience-critical games should persist state to a second medium (localStorage snapshot + Nostr kind-30000 replaceable event) so a new host can resume.
9. Shipping to an Ordinals Inscription (Optional)
A fully self-contained KENOBI game can ship as a single HTML inscription (typically 100–250 KB) — no server, no hosting, no domain. Anyone can load it from any Ordinals explorer and play.
How:
- Minify your game HTML (we used terser + hand-packed JSON).
- Load Three.js, GLTFLoader, etc. from other inscriptions at . Relative-path ordinals hosting means the browser fetches these at play time, and they're cached across games forever.txt
/content/<inscription-id> - Inline the Nostr signing bundle ().txt
<script id="nostrBundle">…</script> - Inscribe with — cost is ~$20–200 depending on fee market.txt
ord wallet inscribe --file game.html --fee-rate N
Three.js + related libraries we reuse across games:
| Asset | Inscription ID |
|---|---|
| Three.js | txt |
| GLTFLoader | txt |
| DRACOLoader | txt |
| Cannon.js | txt |
| Tone.js | txt |
⚠️ Three.js CSP gotcha we hit in production: The Three.js inscription is loaded under strict-mode ES modules. is a read-only property. Code like throws silently in an event handler, making every entity invisible. Always use .
txt
THREE.Object3D#positiontxt
Object.assign(mesh, { position: new THREE.Vector3(x,y,z) })txt
mesh.position.set(x, y, z)Inscription CSP and WebSockets: Ordinals viewers serve inscribed HTML in a sandboxed iframe with a restrictive CSP. WebSocket connections to Nostr relays do work in most viewers today (Ordinals.com, Unisat, Magic Eden) because is generally permissive for . If your target viewer blocks WebSockets, fall back to the manual-paste invite flow (same SDP compression, no Nostr) — users copy/paste the code through Discord/Telegram.
txt
connect-srctxt
wss:10. Complete Build-Your-Own Checklist
Give this whole list to your agent.
10.1 Dependencies
bash
npm install nostr-tools esbuild
10.2 Files to create (all can be one HTML file)
- — see §3.1txt
bundle-nostr-minimal.mjs - with these sections:txt
game.html- — paste the output oftxt
<script id="nostrBundle">txtnostr-min-bundle.js - Constants block: ,txt
NOSTR_RELAYS(change per-game!),txtNOSTR_GAME_TAG,txtNOSTR_KIND_*txtTURN_CREDS - Nostr relay client: ,txt
nostrConnect,txtnostrPublishAll,txtnostrSubscribeAll,txtnostrMakeEventtxtnostrInit - Host flow: ,txt
nostrStartHosting,txtcreateInvite,txtsetupHostDC,txthandleHostMsg,txtbroadcastAsHosttxtacceptAnswer - Guest flow: ,txt
nostrStartBrowsing,txtnostrJoinRoom,txtjoinGame,txthandleClientMsg,txtsendToHosttxtnostrSendAnswerToHost - SDP helpers: ,txt
encode,txtdecode,txtextractParams,txtwaitICEtxtbuildIceServers - Manual-paste fallback UI: + "Copy invite" / "Paste answer" buttonstxt
<textarea id="peerCode"> - Your game code (Three.js scene, entities, input, render loop)
10.3 Concrete porting steps for a new game
- Pick a unique . This is how guests find rooms for your game and ignore everything else.txt
NOSTR_GAME_TAG - Pick four event kinds in the 1000–9999 range. Don't reuse 1312/1314/1315 if you're worried about cross-game pollution on shared relays.
- Get TURN credentials. See §2.3. Drop the username/credential into .txt
TURN_CREDS - Copy the Nostr + WebRTC glue verbatim from lines 500–780, 1200–1525. Rename only the globals that reference the game name.txt
your-game.html - Define your game's message schema. At minimum you need: ,txt
hello,txtinit,txtpos,txtchat,txtleft. Add game-specific ones (e.g.txtjoined,txtshoot,txtpickup).txtwaveStart - Implement two handlers: on host,txt
handleHostMsg(peerId, msg)on guest. Every message type gets atxthandleClientMsg(msg).txtcase - Add a lobby UI: render from on everytxt
nostrRoomstick. Include a refresh button that just re-opens the subscription.txtonRoomsUpdate - Always include manual-paste fallback. If Nostr relays are unreachable (corporate firewall, inscription CSP), the host shows a code + the guest pastes it.
- Test under NAT. Use https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/ to verify your TURN creds hand out candidates. If you only seetxt
relayandtxtsrflx, TURN is mis-configured and symmetric-NAT users will silently fail to connect.txthost - Rate-limit input on the host. A malicious guest can spam messages at 1000 Hz. Cap to 30 Hz server-side: drop any message from a peer arriving faster than 33 ms since its last.txt
pos
10.4 Security checklist (host-authoritative saves you from most of this, but still)
- Host validates all guest inputs. Never trust client-claimed damage, coordinates, or item IDs.
- Bound-check positions — reject type exploits.txt
{x:1e308} - Sanitize chat — escape HTML before rendering, cap 200 chars.
- Rate-limit per-peer — 30 Hz pos, 5 Hz chat, 10 Hz action events.
- Verify the event on any sensitive Nostr message actually matches the expected room host.txt
evt.pubkey - Do not ship any long-lived TURN secret to the client; issue short-lived credentials from a small server endpoint instead.
11. Costs — Real Numbers
| Players | Monthly cost (TURN) | Monthly cost (rest) |
|---|---|---|
| 0–50 concurrent | $0 on most free tiers | $0 |
| 50–500 concurrent | A few dollars/month | $0 — static site on any CDN |
| 500–5000 concurrent | ~$20/mo dedicated TURN capacity | $0 |
| 5000+ concurrent | Regional TURN servers, ~$100/mo | $0 |
The game logic never costs you anything. Every additional player adds zero to your bill because the host player is running the simulation on their own CPU.
The only thing that scales with player count is TURN traffic — and only the ~15–25 % of peers behind symmetric NATs route through it. The other 75 % flow direct P2P, touching no server.
12. Known Limitations (Be Honest With Your Users)
- No persistence. If the host crashes, the session is gone. Solution: periodic state snapshots to Nostr kind-30000 events or localStorage; new host picks up and rebroadcasts.
- ~8–12 players per host is the sweet spot. Beyond that, the host's CPU and upload pipe become the bottleneck. For bigger games, elect multiple hosts per region and sync via Nostr.
- Mobile Safari WebRTC has quirks. It can take longer to gather ICE candidates; we extend to 12 s ontxt
waitICE.txtnavigator.userAgent.includes('iPhone') - Corporate firewalls sometimes block UDP. The TURN variant is a must-have for workplace players.txt
?transport=tcp - Public Nostr relays can rate-limit. If you expect >1000 CCU games, consider running your own dedicated Nostr relay (is the go-to; 50-line config) so you're not a noisy neighbor.txt
strfry - Sensitive game actions aren't encrypted over Nostr. Join requests and offers are signed but public. Don't put secrets in event content. Upgrade to NIP-44 DM encryption if this matters.
13. Reference Files (Open These To Learn More)
| Path | What to read |
|---|---|
txt | Complete Nostr signalling layer |
txt | SDP encoding + WebRTC handshake |
txt | Host DataChannel + broadcast helpers |
txt | Guest DataChannel + input loop |
txt | esbuild config for the signing bundle |
txt | Minimal standalone lobby-only demo |
14. Alternative: Self-Hosted Relay (Method B)
Everything above uses public Nostr relays for signalling + an external TURN provider for NAT traversal (Method A). That's the fully-serverless path, and it's what the reference games ship with.
If you want guaranteed connectivity for every player — e.g. a PvP title where a 15–25 % NAT-drop rate is unacceptable — you can replace both Nostr and the external TURN service with a single self-hosted WebRTC relay running on one small VPS. We call this Method B.
| Method A — Nostr + external TURN | Method B — Self-hosted relay | |
|---|---|---|
| Signalling | Public Nostr relays shuttle SDP | Your own VPS handles SDP exchange |
| NAT traversal | Separate TURN provider | The same VPS doubles as TURN fallback |
| Game traffic | Pure P2P after handshake | Routed through the relay (one extra hop) |
| Infra cost | $0 (Nostr) + whatever TURN costs | One small VPS — a free-tier box is usually enough |
| When to use | Casual / co-op titles where occasional drops are fine | Competitive / PvP where every player must connect |
14.1 Where to host it
Any Linux VPS with a stable public IP works. Popular options:
- Oracle Cloud Always-Free Ampere A1 (4 vCPU, 24 GB RAM, free forever) — runs the reference relay very comfortably and is what the Ord Dropz reference games use
- Hetzner, DigitalOcean, Fly.io, AWS Lightsail — similar shapes, small monthly cost
- Any home server with a static IP or a DDNS setup
Pick whichever you're comfortable operating. The relay code doesn't care about the provider.
14.2 Architecture
Every browser opens a single WebRTC DataChannel to a relay process on your VPS. The relay maintains a pool of pre-provisioned WebRTC slots (20 is a reasonable default); each browser claims one slot. Once two browsers are both connected, the relay routes payloads between them. It never parses or simulates game traffic — it only forwards bytes.
txt
room-sendtxt
Player A (host) Your Relay Player B (guest)
browser Node process browser
│ ┌──────────────┐ │
│ WebRTC DC ─────────┤ slot N (UDP) ├──── WebRTC DC │
│ │ │ │
│ │ slot M (UDP) ├◄──────────────┤
│ └──────────────┘ │
│ │
│ host→'room-create' ─────────────────────────────► │
│ ◄─────────────── 'room-event: join' ◄──guest joins │
│ ◄─────────────── game traffic both ways ──────────►│
One browser ⇄ one slot ⇄ one other browser. The relay only forwards — it doesn't simulate the game.
14.3 What you need
- One VPS with a static public IP and root shell access
- Node.js 18+ on the VPS
- pm2 or systemd for process supervision
- UDP ports opened — one per slot, starting from a known base port (e.g. 40000), plus one STUN/control port
- ~100 MB disk for logs plus two tiny persistence files (see 14.5)
14.4 Reference relay shape
The reference implementation is ~600 lines of plain JS using werift (a pure-JS WebRTC library — no binary deps). It:
- Pre-provisions N WebRTC slots on first boot. Each slot is a long-lived with its own UDP port.txt
RTCPeerConnection - Generates one DTLS certificate for the whole server and one ICE credential pair (ufrag + pwd) per slot. Saves both to disk.
- Listens for browsers making TURN Allocate requests on a STUN control port.
- Opens a DataChannel to each connecting browser and routes /txt
room-create/txtroom-join/txtroom-sendmessages between them.txtroom-leave
Slot lifecycle:
txt
free → pending → connected → recycling → free
- — STUN ALLOCATE received, DTLS handshake in progresstxt
pending - — DataChannel open, player is activetxt
connected
A 20-second pending timeout auto-recycles slots where the DTLS handshake never completes. Without this, failed connection attempts quietly exhaust the pool.
If all slots fill, new joiners receive an error and must retry. Grow the pool by bumping the slot count; the next restart adds new slots without regenerating credentials for the existing ones (see 14.5).
14.5 Token permanence — the critical piece
Each slot's ICE credentials (ufrag + pwd) and the server's DTLS certificate get baked into the game HTML at inscription time. They must survive every server restart or every deployed game inscription breaks.
Two JSON files on the server persist this state:
| File | Purpose |
|---|---|
txt | Persistent DTLS certificate ( txt txt txt |
txt | Per-slot ICE credentials ( txt txt |
The credential-ensuring function should be additive: if you grow from 20 to 30 slots, slots 0-19 keep their exact credentials and only slots 20-29 get new ones. Old inscribed games with 10-slot tokens continue to work forever on slots 0-9.
Back up these two files the moment your relay finishes its first boot. Losing them forces every game inscription pointing at that relay to be re-inscribed.
14.6 Room lifecycle
- Host browser connects → DC opens → server registers the peer → host sends → room entry createdtxt
room-create - Guest browser connects → DC opens → guest sends → relay broadcaststxt
room-join {roomId}to everyone in the roomtxtroom-event: join - Host tab closes → DC fires → relay evicts the peer → room entry deletedtxt
closed - No artificial room timers — rooms live exactly as long as the host's tab is open, hours if needed
- Connected peers that haven't joined a room within 15 seconds are evicted (prevents idle connections from holding slots)
14.7 Token format
Each slot is described to the client by one comma-delimited string:
txt
O,<slot index>,<ice-ufrag>,<ice-pwd>,<DTLS fingerprint hex>,<public-ip>:<udp port>
Example shape (all values are placeholders — your relay emits real ones at first boot):
txt
O,0,YOUR_UFRAG,YOUR_PWD,YOUR_FINGERPRINT,YOUR_RELAY_IP:PORT
O,1,YOUR_UFRAG,YOUR_PWD,YOUR_FINGERPRINT,YOUR_RELAY_IP:PORT+2
...
All slots on one server share the same DTLS fingerprint; each has its own ufrag/pwd/port.
14.8 Client-side relay connector
js
var RELAY_IP = 'YOUR_RELAY_PUBLIC_IP';
var RELAY_STUN_PORT = 4444;
var _relayPingTimer = null;
var _relayPending = {};
// Tokens — one per slot, generated by your relay at first boot. Never change.
var RELAY_TOKENS = [
// 'O,0,YOUR_UFRAG,YOUR_PWD,YOUR_FINGERPRINT,YOUR_RELAY_IP:PORT',
// 'O,1,YOUR_UFRAG,YOUR_PWD,YOUR_FINGERPRINT,YOUR_RELAY_IP:PORT',
// ...
];
function relayRequest(action, params, _attempt) {
_attempt = _attempt || 0;
return new Promise(function(resolve, reject) {
var id = Math.random().toString(36).slice(2);
var timer = setTimeout(function() {
delete _relayPending[id];
if (_attempt < 3 && relayDC && relayDC.readyState === 'open') {
relayRequest(action, params, _attempt + 1).then(resolve).catch(reject);
} else {
reject(new Error('relay timeout: ' + action));
}
}, 8000);
_relayPending[id] = {
resolve: function(r) { clearTimeout(timer); resolve(r); },
reject: function(e) { clearTimeout(timer); reject(e); }
};
relayDC.send(JSON.stringify(Object.assign({ action: action, id: id }, params)));
});
}
// Heartbeat — paste inside dc.onopen:
if (_relayPingTimer) clearInterval(_relayPingTimer);
_relayPingTimer = setInterval(function() {
if (relayDC && relayDC.readyState === 'open') {
try { relayDC.send(JSON.stringify({ action: 'ping' })); } catch(e) {}
}
}, 5000);
From there, rooms work the same way they do in Method A — the host sends , guests send , and host broadcasts go out via payloads.
txt
room-createtxt
room-jointxt
room-send14.9 Inscribe the relay config separately
If you bake and directly into the game HTML, you are stuck with that server forever. Rotating to a new VPS, adding slots, or responding to an IP change all require re-inscribing the whole game.
txt
RELAY_IPtxt
RELAY_TOKENSThe production pattern is to inscribe the relay config as its own small inscription and fetch it from the game at boot:
json
{
"RELAY_IP": "YOUR_RELAY_PUBLIC_IP",
"RELAY_STUN_PORT": 4444,
"RELAY_TOKENS": [
"O,0,...",
"O,1,..."
]
}
js
const CONFIG_INSCRIPTION_ID = 'YOUR_CONFIG_INSCRIPTION_ID';
const cfg = await fetch('/content/' + CONFIG_INSCRIPTION_ID).then(r => r.json());
window.RELAY_IP = cfg.RELAY_IP;
window.RELAY_STUN_PORT = cfg.RELAY_STUN_PORT;
window.RELAY_TOKENS = cfg.RELAY_TOKENS;
// ... now run your relay connector code, using these window-level values
To rotate the relay later — new VPS, added slots, port change — just inscribe a new config. The game HTML never has to change. Common patterns for finding the latest config:
- Children / Parent — config inscriptions are children of a parent you control; the game fetches and picks the most recenttxt
/r/children/<parent> - Sat delegate — inscribe each config on a fixed sat you control; the game fetches and always gets the newest versiontxt
/r/sat/<sat>/at/-1/content - Registry — a tiny index inscription lists the current config's ID; update the index when you rotate
Whichever you pick, the game HTML is never re-inscribed. Only the tiny config is.
14.10 Operating the relay
bash
# Start under pm2
pm2 start relay-server.js --name kenobi-relay
# Check status
pm2 status
# Tail logs without disturbing connected players
pm2 logs kenobi-relay --lines 40 --nostream
# Health check (the server exposes /health on its control port)
curl http://YOUR_RELAY_IP:CONTROL_PORT/health
A healthy relay exposes per-slot states ( / / ) and a list of active rooms on its health endpoint. Watch these to know when to expand the pool.
txt
freetxt
pendingtxt
connected14.11 Pitfalls we hit in production
| Pitfall | Fix |
|---|---|
| werift fires txt txt txt | Null-check the payload before wrapping it; ignore empty frames |
| A slot's DTLS handshake fails silently and the slot stays txt | Add a 20-second pending timeout that force-recycles the slot |
| Credential-ensuring function regenerates ALL creds when expanding slot count | Make it additive: preserve existing entries, only generate new ones for the new indexes |
| Idle-peer eviction kills hosts between games | Skip idle-eviction for peers that currently hold a txt |
14.12 Checklist — standing up your own relay
- VPS provisioned with static public IP (Oracle Cloud Always-Free, Hetzner, DO, Fly.io — pick one)
- Node.js 18+, pm2 (or systemd)
- UDP ports open — one per slot (starting from a known base) plus the STUN/control port
- Build the ~600-line relay server with werift (or port from your reference)
- First boot: let it generate andtxt
relay-dtls.json— back both up immediatelytxtrelay-pool.json - Capture the tokens printed to stdout on startup
- Inscribe the config JSON as its own Ordinals inscription
- In the game HTML, fetch the config at startup and wire /txt
RELAY_IPinto the relay connectortxtRELAY_TOKENS - Ship the game HTML as its own Ordinals inscription
When a config change is needed later, re-inscribe only the config — never the game.
15. One-Liner Summary for Your Landing Page
KENOBI — ship multiplayer games with no backend. The Kryptographic Ephemeral Network for On-chain Bitcoin-Inscribed gaming lets players find each other on Nostr relays, exchange a handshake, and connect browser-to-browser over WebRTC.
Built and battle-tested on live multiplayer titles on Bitcoin. Every number, path, and code snippet in this document is pulled from real production files — not theoretical. Your agent can verify every line by reading the referenced sources.