Developer Guide

The KENOBI Method

The first method for shipping real-time peer-to-peer multiplayer games directly from a Bitcoin Ordinals inscription — no game server, no account system, no hosting bill.

Invented by Obi-Wan Satoshi · Reference guide for building games that list on the Ord Dropz Game Hub.

TURN placeholders (YOUR_TURN_USERNAME / YOUR_TURN_CREDENTIAL) in the code samples are yours to fill in — plug in whatever TURN provider you use.

Introducing KENOBI — Serverless Multiplayer Gaming on Bitcoin

KENOBIKryptographic 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.
LetterStands forWhat it means in the stack
KryptographicEphemeral secp256k1 keypairs generated per session sign every Nostr event. No account, no wallet, no leaked identity.
EphemeralEvery session is disposable — keys, rooms, and game state all dissolve when the host closes the tab.
NetworkThree public Nostr relays form the decentralized signaling layer (nos.lol, damus, wellorder).
On-chainGame logic and assets are inscribed on Bitcoin — permanently resolvable by any Ordinals viewer.
Bitcoin-InscribedThe game HTML + 3D models + audio live as Bitcoin Ordinals inscriptions, served directly from any Ord node.
gamingReal-time multiplayer, host-authoritative, 10+ concurrent players per room.

0. TL;DR — What's Revolutionary About KENOBI

Traditional multiplayerThis stack
Central game server (Node/Go/C#) on EC2/RailwayZero game server. The host player IS the server.
Matchmaking service + account systemZero 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 CCUBandwidth = players' upload. Your cost: $0.
Must run HTTPS, certs, CORS, rate limitsStatic 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:
KindDirectionPurposeNIP
30311Host → worldRoom heartbeat (replaceable, re-published every 30 s)NIP-53 Live Events
1312Guest → host"I want to join your room"Custom
1314Host → guestCompressed SDP offerCustom
1315Guest → hostCompressed SDP answerCustom
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
txt
['t', NOSTR_GAME_TAG]
so guests filter only rooms for your game. Change this string for your game. Bump the
txt
-v1
when you make a breaking protocol change; old clients on
txt
-v1
will silently stop seeing new
txt
-v2
rooms.

2.2 WebRTC Transport Layer

Once both peers have each other's SDP, the standard browser
txt
RTCPeerConnection
takes over. We use:
  • One
    txt
    RTCDataChannel
    per peer
    named
    txt
    'game'
  • txt
    {ordered: false}
    — unordered delivery, because we'd rather drop a stale position update than pause the channel waiting to retransmit
  • 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
txt
{ username, credential }
values into
txt
TURN_CREDS
and point
txt
turn:...
at your host. The rest of the KENOBI stack doesn't care where TURN comes from.

3. The Nostr Client — Raw WebSocket, No Library

We deliberately do not use
txt
SimplePool
or
txt
RelayPool
from
txt
nostr-tools
at runtime. Reasons:
  • 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
txt
nostr-tools
for signing — key generation, pubkey derivation, event finalization (schnorr signature). The relay socket, subscription, and publish are all hand-rolled.

3.1 Bundling just the signing helpers (file:
txt
webrtc-poc/bundle-nostr-minimal.mjs
)

js
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
txt
<script id="nostrBundle">...</script>
. This gives you a fully self-contained HTML file with no external JS dependency.

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
txt
seen[evt.id]
deduping is important — the same event arrives from all three relays and you only want to process it once.

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
txt
RTCPeerConnection
per guest and ship the offer:
js
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
txt
nostrRooms
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.
Join 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
txt
peerId
dance — why it matters

When a host is popular, it may have 5 simultaneous
txt
pendingInvites
(one per guest in handshake). Answers arrive asynchronously and must be matched to the correct
txt
RTCPeerConnection
— picking the wrong one causes DTLS to silently fail.
js
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
txt
peerId
in your offer/answer JSON payloads.

5. 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
txt
onicecandidate(null)
— some browsers take 15+ seconds. Cap it:
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
txt
RTCPeerConnection
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.

7.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 kindRateNotes
Player position10 HzOnly on meaningful delta (>0.1 units or >0.05 rad)
Chatevent-drivenCap message to 200 chars
Shooting / hitsevent-drivenSend on fire, not per-frame
Enemy position10 HzHost → guests, cull entities >300 u away from each guest
Full state resyncevery 2 sCheap 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
txt
disconnected
, wait 5 s before calling
txt
restartIce()
— transient mobile-network drops usually recover on their own.
Other hardening:
  • Host broadcasts
    txt
    {type:'left', id}
    when
    txt
    dc.onclose
    fires so every guest's player list updates.
  • 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:
  1. Minify your game HTML (we used terser + hand-packed JSON).
  2. Load Three.js, GLTFLoader, etc. from other inscriptions at
    txt
    /content/<inscription-id>
    . Relative-path ordinals hosting means the browser fetches these at play time, and they're cached across games forever.
  3. Inline the Nostr signing bundle (
    txt
    <script id="nostrBundle">…</script>
    ).
  4. Inscribe with
    txt
    ord wallet inscribe --file game.html --fee-rate N
    — cost is ~$20–200 depending on fee market.
Three.js + related libraries we reuse across games:
AssetInscription ID
Three.js
txt
a4a6f99205628bdc3ca2143a4e380f7ebc576b4414c16dffdb34be28337ffe83i0
GLTFLoader
txt
d9f5134fdd4a1ae7a5c3fe1b42876cc4e18f4ce404a39394f9157679c60e965fi0
DRACOLoader
txt
00ae91a4f7f4f6fa98c1deb0f57359079f7b5299094378ff15fa1c7f4366db3ci0
Cannon.js
txt
39df128491c33911ebff0afd1130c8b534311ed5258bbbd29e90ab65e0bf9b2bi0
Tone.js
txt
44740a1f30efb247ef41de3355133e12d6f58ab4dc8a3146648e2249fa9c6a39i0
⚠️ Three.js CSP gotcha we hit in production: The Three.js inscription is loaded under strict-mode ES modules.
txt
THREE.Object3D#position
is a read-only property. Code like
txt
Object.assign(mesh, { position: new THREE.Vector3(x,y,z) })
throws silently in an event handler, making every entity invisible. Always use
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
txt
connect-src
is generally permissive for
txt
wss:
. 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.

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)

  1. txt
    bundle-nostr-minimal.mjs
    — see §3.1
  2. txt
    game.html
    with these sections:
    • txt
      <script id="nostrBundle">
      — paste the output of
      txt
      nostr-min-bundle.js
    • Constants block:
      txt
      NOSTR_RELAYS
      ,
      txt
      NOSTR_GAME_TAG
      (change per-game!),
      txt
      NOSTR_KIND_*
      ,
      txt
      TURN_CREDS
    • Nostr relay client:
      txt
      nostrConnect
      ,
      txt
      nostrPublishAll
      ,
      txt
      nostrSubscribeAll
      ,
      txt
      nostrMakeEvent
      ,
      txt
      nostrInit
    • Host flow:
      txt
      nostrStartHosting
      ,
      txt
      createInvite
      ,
      txt
      setupHostDC
      ,
      txt
      handleHostMsg
      ,
      txt
      broadcastAsHost
      ,
      txt
      acceptAnswer
    • Guest flow:
      txt
      nostrStartBrowsing
      ,
      txt
      nostrJoinRoom
      ,
      txt
      joinGame
      ,
      txt
      handleClientMsg
      ,
      txt
      sendToHost
      ,
      txt
      nostrSendAnswerToHost
    • SDP helpers:
      txt
      encode
      ,
      txt
      decode
      ,
      txt
      extractParams
      ,
      txt
      waitICE
      ,
      txt
      buildIceServers
    • Manual-paste fallback UI:
      txt
      <textarea id="peerCode">
      + "Copy invite" / "Paste answer" buttons
    • Your game code (Three.js scene, entities, input, render loop)

10.3 Concrete porting steps for a new game

  1. Pick a unique
    txt
    NOSTR_GAME_TAG
    .
    This is how guests find rooms for your game and ignore everything else.
  2. 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.
  3. Get TURN credentials. See §2.3. Drop the username/credential into
    txt
    TURN_CREDS
    .
  4. Copy the Nostr + WebRTC glue verbatim from
    txt
    your-game.html
    lines 500–780, 1200–1525. Rename only the globals that reference the game name.
  5. Define your game's message schema. At minimum you need:
    txt
    hello
    ,
    txt
    init
    ,
    txt
    pos
    ,
    txt
    chat
    ,
    txt
    left
    ,
    txt
    joined
    . Add game-specific ones (e.g.
    txt
    shoot
    ,
    txt
    pickup
    ,
    txt
    waveStart
    ).
  6. Implement two handlers:
    txt
    handleHostMsg(peerId, msg)
    on host,
    txt
    handleClientMsg(msg)
    on guest. Every message type gets a
    txt
    case
    .
  7. Add a lobby UI: render from
    txt
    nostrRooms
    on every
    txt
    onRoomsUpdate
    tick. Include a refresh button that just re-opens the subscription.
  8. Always include manual-paste fallback. If Nostr relays are unreachable (corporate firewall, inscription CSP), the host shows a code + the guest pastes it.
  9. Test under NAT. Use https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/ to verify your TURN creds hand out
    txt
    relay
    candidates. If you only see
    txt
    srflx
    and
    txt
    host
    , TURN is mis-configured and symmetric-NAT users will silently fail to connect.
  10. Rate-limit input on the host. A malicious guest can spam
    txt
    pos
    messages at 1000 Hz. Cap to 30 Hz server-side: drop any message from a peer arriving faster than 33 ms since its last.

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
    txt
    {x:1e308}
    type exploits.
  • 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
    txt
    evt.pubkey
    on any sensitive Nostr message actually matches the expected room host.
  • 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

PlayersMonthly cost (TURN)Monthly cost (rest)
0–50 concurrent$0 on most free tiers$0
50–500 concurrentA few dollars/month$0 — static site on any CDN
500–5000 concurrent~$20/mo dedicated TURN capacity$0
5000+ concurrentRegional 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)

  1. 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.
  2. ~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.
  3. Mobile Safari WebRTC has quirks. It can take longer to gather ICE candidates; we extend
    txt
    waitICE
    to 12 s on
    txt
    navigator.userAgent.includes('iPhone')
    .
  4. Corporate firewalls sometimes block UDP. The
    txt
    ?transport=tcp
    TURN variant is a must-have for workplace players.
  5. Public Nostr relays can rate-limit. If you expect >1000 CCU games, consider running your own dedicated Nostr relay (
    txt
    strfry
    is the go-to; 50-line config) so you're not a noisy neighbor.
  6. 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)

PathWhat to read
txt
your-game.html
Complete Nostr signalling layer
txt
your-game.html
SDP encoding + WebRTC handshake
txt
your-game.html
Host DataChannel + broadcast helpers
txt
your-game.html
Guest DataChannel + input loop
txt
webrtc-poc/bundle-nostr-minimal.mjs
esbuild config for the signing bundle
txt
webrtc-poc/lobby-test-template.html
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 TURNMethod B — Self-hosted relay
SignallingPublic Nostr relays shuttle SDPYour own VPS handles SDP exchange
NAT traversalSeparate TURN providerThe same VPS doubles as TURN fallback
Game trafficPure P2P after handshakeRouted through the relay (one extra hop)
Infra cost$0 (Nostr) + whatever TURN costsOne small VPS — a free-tier box is usually enough
When to useCasual / co-op titles where occasional drops are fineCompetitive / 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
txt
room-send
payloads between them. It never parses or simulates game traffic — it only forwards bytes.
txt
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:
  1. Pre-provisions N WebRTC slots on first boot. Each slot is a long-lived
    txt
    RTCPeerConnection
    with its own UDP port.
  2. Generates one DTLS certificate for the whole server and one ICE credential pair (ufrag + pwd) per slot. Saves both to disk.
  3. Listens for browsers making TURN Allocate requests on a STUN control port.
  4. Opens a DataChannel to each connecting browser and routes
    txt
    room-create
    /
    txt
    room-join
    /
    txt
    room-send
    /
    txt
    room-leave
    messages between them.
Slot lifecycle:
txt
free → pending → connected → recycling → free
  • txt
    pending
    — STUN ALLOCATE received, DTLS handshake in progress
  • txt
    connected
    — DataChannel open, player is active
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:
FilePurpose
txt
relay-dtls.json
Persistent DTLS certificate (
txt
certPem
+
txt
keyPem
+
txt
signatureHash
). Same fingerprint every restart.
txt
relay-pool.json
Per-slot ICE credentials (
txt
ufrag
+
txt
pwd
). Same per-slot values every restart.
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
    txt
    room-create
    → room entry created
  • Guest browser connects → DC opens → guest sends
    txt
    room-join {roomId}
    → relay broadcasts
    txt
    room-event: join
    to everyone in the room
  • Host tab closes → DC fires
    txt
    closed
    → relay evicts the peer → room entry deleted
  • 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
txt
room-create
, guests send
txt
room-join
, and host broadcasts go out via
txt
room-send
payloads.

14.9 Inscribe the relay config separately

If you bake
txt
RELAY_IP
and
txt
RELAY_TOKENS
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.
The 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
    txt
    /r/children/<parent>
    and picks the most recent
  • Sat delegate — inscribe each config on a fixed sat you control; the game fetches
    txt
    /r/sat/<sat>/at/-1/content
    and always gets the newest version
  • 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 (
txt
free
/
txt
pending
/
txt
connected
) and a list of active rooms on its health endpoint. Watch these to know when to expand the pool.

14.11 Pitfalls we hit in production

PitfallFix
werift fires
txt
dc.onMessage
with
txt
undefined
when the buffer is empty →
txt
Buffer.from(undefined)
throws
Null-check the payload before wrapping it; ignore empty frames
A slot's DTLS handshake fails silently and the slot stays
txt
pending
forever
Add a 20-second pending timeout that force-recycles the slot
Credential-ensuring function regenerates ALL creds when expanding slot countMake it additive: preserve existing entries, only generate new ones for the new indexes
Idle-peer eviction kills hosts between gamesSkip idle-eviction for peers that currently hold a
txt
roomId

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
    txt
    relay-dtls.json
    and
    txt
    relay-pool.json
    back both up immediately
  • 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_IP
    /
    txt
    RELAY_TOKENS
    into the relay connector
  • 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.
Ready to ship?
Inscribe your KENOBI game, then list it on Ord Dropz.
Players mint a copy, own it forever, and play straight from the Bitcoin inscription.