Initial commit: P2P coin flip with QR signaling and commit-reveal protocol

This commit is contained in:
Ole 2026-06-13 21:18:08 +02:00
commit bdd85db8a7
7 changed files with 1234 additions and 0 deletions

113
README.md Normal file
View file

@ -0,0 +1,113 @@
# randomp2p
**Vertrauensloser Multiplayer-Münzwurf P2P im Browser, keine Server, kein Trust.**
Zwei bis N Spieler verbinden sich via WebRTC (simple-peer) und führen ein kryptografisches Commit-Reveal-Protokoll aus, um einen garantiert fairen Münzwurf zu erhalten. Solange **ein** Teilnehmer ehrlich ist, ist das Ergebnis zufällig und manipulationssicher.
## Features
- **P2P** Kein Server, kein Account, keine zentrale Instanz
- **Trustless** Commit-Reveal-Protokoll mit SHA-256-Bindung, jeder verifiziert
- **QR-Bootstrapping** SDP-Offers/Answers werden als QR-Codes ausgetauscht
- **Multiplayer (2n)** Beliebig viele Teilnehmer in einer Runde
- **Coin-Animation** 3D-Münzwurf via CSS
- **100% Vanilla JS** Keine Build-Tools, kein npm, keine Abhängigkeiten außer CDN-Libs
## Quick Start
```bash
git clone <repo-url>
cd randomp2p
python3 -m http.server 8080
```
Dann auf zwei (oder mehr) Geräten `http://localhost:8080` öffnen.
> WebRTC + Kamera (`getUserMedia`) + Web Crypto API brauchen einen **sicheren Kontext**. Lokal via localhost funktioniert das. Für andere Geräte im selben Netzwerk die lokale IP verwenden.
## Spielanleitung
### Host
1. **"Raum erstellen"** QR-Code mit SDP Offer wird angezeigt
2. **"Antwort-QR scannen"** Kamera startet, scannt die Antwort-QRs der Beitreter
3. Jeder neue Spieler erscheint in der Liste
4. **"Münzwurf starten"** Übergang in den Spiel-Screen
5. **"Münzwurf starten"** Protokoll beginnt (Commit → Reveal → Ergebnis)
### Beitreter
1. **"Raum beitreten"** Kamera startet
2. QR des Hosts scannen
3. **Antwort-QR zeigen** Host scannt diesen QR
4. Verbindung steht, warten auf Start
5. Protokoll läuft automatisch durch Ergebnis erscheint
### Protokoll-Phasen
| Phase | Beschreibung |
|-------|-------------|
| **Commit** | Jeder Spieler generiert 32 Zufallsbytes und sendet den SHA-256-Hash an alle |
| **Warten** | Sammle Commits aller Teilnehmer |
| **Reveal** | Jeder sendet die rohen Zufallsbytes an alle |
| **Verify** | Jeder prüft: SHA-256(received) == stored_commit |
| **Result** | XOR aller Secrets → Parity (0=Kopf, 1=Zahl) |
## Architektur
```
randomp2p/
├── index.html 5 Screens + CDN-Libs
├── style.css Dark-Theme, Coin-Animation
├── crypto.js SHA-256, Zufallsbytes, XOR-Combine
├── p2p.js MeshNet: simple-peer, QR-Signaling, Datenkanal
├── protocol.js Commit-Reveal State Machine
└── app.js UI-Routing, Kamera/QR-Scan, Verkabelung
```
### Abhängigkeiten (CDN)
| Library | Zweck |
|---------|-------|
| [simple-peer](https://github.com/feross/simple-peer) | WebRTC-Datenkanäle |
| [qrcodejs](https://github.com/davidshimjs/qrcodejs) | QR-Code-Generierung |
| [jsQR](https://github.com/cozmo/jsQR) | QR-Code-Scanning per Kamera |
## Netzwerk-Topologie
**Best Case (gleiches WiFi):**
QR-SDP-Austausch zwischen Host und jedem Peer. Host teilt IP-Liste → potentiell volles Mesh über `signal_relay`.
**Worst Case (NAT/Internet):**
Stern-Topologie über den Host. Protokoll funktioniert trotzdem die Fairness ist nicht von der Topologie abhängig.
## Warum ist das fair?
Das Commit-Reveal-Protokoll garantiert:
1. **Keine späte Manipulation:** Der SHA-256-Commit bindet jeden Spieler vor dem Reveal an sein Secret (Preimage-Resistenz)
2. **Keine Absprache nötig:** Solange **ein** Teilnehmer ehrlich Zufallsbytes beisteuert, ist das XOR-Ergebnis zufällig
3. **Volle Transparenz:** Jeder Teilnehmer rechnet lokal alle Prüfungen und das Endergebnis
Mathematisch: `result = S_1 ⊕ S_2 ⊕ ... ⊕ S_n`. Wenn `S_k` echt zufällig und vor `S_1..S_{k-1}, S_{k+1}..S_n` festgelegt wurde, ist `result` zufällig unabhängig von allen anderen Secrets.
## IPFS-Deployment
```bash
ipfs add -r .
# → CID notieren, über IPFS-Gateway aufrufbar
# → Gateway-URL als QR-Code in die App einbauen
```
## Ausblick / TODOs
- [ ] Volles Mesh: Peers verbinden sich direkt via relayed signaling
- [ ] Timeout + Reconnect bei Verbindungsabbruch
- [ ] Raum-Code als Alternative zum QR-Scan
- [ ] TURN-Server-Konfiguration für NAT-Traversal
- [ ] Mehrere Runden mit Historie
- [ ] PWA-Manifest + ServiceWorker für IPFS-Distribution
## Lizenz
MIT

286
app.js Normal file
View file

@ -0,0 +1,286 @@
(function() {
'use strict';
let mesh = null;
let protocol = null;
let role = null;
let scanningHost = false;
const $ = id => document.getElementById(id);
/* ─── Screen routing ─── */
function show(name) {
['start','host','join','game','result'].forEach(s =>
$(`screen-${s}`).classList.toggle('active', s === name)
);
}
/* ─── Player list ─── */
function renderPlayers(containerId) {
const list = $(containerId);
if (!list) return;
const all = [mesh.myPeerId, ...mesh.getPeerIds()];
const seen = new Set();
list.innerHTML = '';
all.forEach(id => {
if (seen.has(id)) return;
seen.add(id);
const isMe = id === mesh.myPeerId;
const li = document.createElement('li');
li.className = 'player-chip' + (isMe ? ' self' : '');
li.innerHTML = `<span class="dot online"></span>${isMe ? 'Du' : id.slice(0, 8)}`;
list.appendChild(li);
});
}
/* ─── QR ─── */
let qrHost = null;
function showQR(containerId, data) {
const c = $(containerId);
if (!c) return;
c.innerHTML = '';
qrHost = new QRCode(c, { text: data, width: 200, height: 200, correctLevel: QRCode.CorrectLevel.L });
}
/* ─── Camera ─── */
let camStream = null;
let camRaf = null;
async function camStart(videoId) {
await camStop();
camStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 320 }, height: { ideal: 240 } }
});
const v = $(videoId);
v.srcObject = camStream;
v.setAttribute('playsinline', '');
v.classList.add('active');
await v.play();
return v;
}
function camStop() {
if (camRaf) { cancelAnimationFrame(camRaf); camRaf = null; }
if (camStream) { camStream.getTracks().forEach(t => t.stop()); camStream = null; }
}
function camScan(videoId, canvasId, callback) {
const v = $(videoId);
const c = $(canvasId);
const ctx = c.getContext('2d');
camRaf = requestAnimationFrame(function tick() {
if (!v.videoWidth) { camRaf = requestAnimationFrame(tick); return; }
c.width = v.videoWidth; c.height = v.videoHeight;
ctx.drawImage(v, 0, 0);
const img = ctx.getImageData(0, 0, c.width, c.height);
const code = jsQR(img.data, img.width, img.height);
if (code && code.data) {
try {
const p = JSON.parse(code.data);
if (p.v === 1) { callback(p, code.data); return; }
} catch(e) {}
}
camRaf = requestAnimationFrame(tick);
});
}
/* ─── Coin animation ─── */
function flipCoin(resultBit, areaId) {
const area = $(areaId);
if (!area) return;
area.hidden = false;
const coin = area.querySelector('.coin');
coin.classList.remove('flipping','result-kopf','result-zahl');
void coin.offsetWidth;
coin.classList.add('flipping');
setTimeout(() => {
coin.classList.remove('flipping');
coin.classList.add(resultBit === 0 ? 'result-kopf' : 'result-zahl');
}, 1500);
}
/* ─── Protocol phases UI ─── */
function updatePhases(state) {
const map = { IDLE:'ready', STARTING:'ready', COMMITTING:'commit',
WAITING_FOR_COMMITS:'commit', REVEALING:'reveal',
WAITING_FOR_REVEALS:'reveal', VERIFYING:'verify', COMPLETE:'done' };
const active = map[state] || 'ready';
let doneSeen = false;
['ready','commit','reveal','verify','done'].forEach(p => {
const el = $('phase-'+p);
if (!el) return;
el.classList.toggle('active', p === active && !doneSeen);
el.classList.toggle('done', doneSeen);
if (p === active) doneSeen = true;
});
}
/* ─── Game setup ─── */
function setupGame(autostart) {
show('game');
renderPlayers('game-player-list');
$('btn-start-protocol').hidden = autostart || role !== 'host';
$('coin-area').hidden = true;
$('result-area').hidden = true;
$('protocol-text').textContent = autostart ? 'Starte Protokoll...' : 'Bereit zum Münzwurf';
updatePhases('IDLE');
protocol = new CoinFlipProtocol(mesh);
protocol.onstatechange = updatePhases;
protocol.onprogress = (phase, rcvd, total) => {
$('protocol-text').textContent =
phase === 'commit' ? `Commits: ${rcvd}/${total}` : `Reveals: ${rcvd}/${total}`;
};
protocol.oncomplete = result => {
const text = result === 0 ? 'KOPF' : 'ZAHL';
$('protocol-text').textContent = `Ergebnis: ${text}!`;
flipCoin(result, 'coin-area');
$('result-area').hidden = false;
$('result-text').textContent = text;
$('btn-start-protocol').hidden = true;
};
protocol.onerror = err => {
$('protocol-text').textContent = 'Fehler: ' + err;
};
mesh.ondata = (peerId, msg) => {
if (msg.type === 'protocol_start' && protocol && protocol.state === 'IDLE') {
$('btn-start-protocol').hidden = true;
protocol.start();
return;
}
if (msg.type === 'protocol' && protocol) {
protocol.handleMessage(peerId, msg);
}
};
if (autostart) {
protocol.start();
}
}
/* ─── Start screen ─── */
$('btn-create').addEventListener('click', () => {
role = 'host';
mesh = new MeshNet();
mesh.onpeerconnect = pid => {
renderPlayers('host-player-list');
$('btn-start-game').disabled = mesh.getCount() < 1;
};
mesh.onpeerdisconnect = () => {
renderPlayers('host-player-list');
$('btn-start-game').disabled = mesh.getCount() < 1;
};
mesh.onqrupdate = d => showQR('qr-host', d);
mesh.createRoom();
show('host');
renderPlayers('host-player-list');
$('btn-start-game').disabled = true;
});
/* ─── Host: scan answer QR ─── */
$('btn-scan-answer').addEventListener('click', async () => {
if (camStream) { camStop(); return; }
try {
await camStart('host-camera');
$('host-scan-status').textContent = 'Warte auf Antwort-QR...';
scanningHost = true;
camScan('host-camera', 'host-camera-canvas', (parsed, raw) => {
if (!parsed.s || !parsed.id) return;
if (parsed.to && parsed.to !== mesh.myPeerId) return;
camStop();
scanningHost = false;
$('host-scan-status').textContent = 'Verbunden!';
mesh.feedAnswer(raw);
$('btn-scan-answer').textContent = 'Antwort-QR scannen';
});
$('btn-scan-answer').textContent = 'Scannen beenden';
} catch (e) {
$('host-scan-status').textContent = 'Kamera-Fehler';
}
});
/* ─── Join screen ─── */
$('btn-join').addEventListener('click', async () => {
role = 'peer';
mesh = new MeshNet();
show('join');
$('join-answer-section').hidden = true;
$('join-scan-status').textContent = 'QR-Code scannen...';
mesh.onanswerready = answerData => {
$('join-answer-section').hidden = false;
showQR('qr-answer', answerData);
$('join-scan-status').textContent = 'Antwort-QR dem Host zeigen';
camStop();
};
mesh.onpeerconnect = () => {
renderPlayers('join-player-list');
$('join-scan-status').textContent = 'Verbunden warte auf Start...';
};
mesh.ondata = (peerId, msg) => {
if (msg.type === 'protocol_start') {
setupGame(true);
}
};
try {
await camStart('join-camera');
let scanned = false;
camScan('join-camera', 'join-camera-canvas', (parsed, raw) => {
if (scanned) return;
if (!parsed.s || !parsed.id) return;
scanned = true;
$('join-scan-status').textContent = 'Verbinde...';
mesh.joinFromQR(raw);
});
} catch (e) {
$('join-scan-status').textContent = 'Kamera nicht verfügbar';
}
});
/* ─── Host: start game button (in host screen) ─── */
$('btn-start-game').addEventListener('click', () => {
setupGame(false);
});
/* ─── Host: start protocol button (in game screen) ─── */
$('btn-start-protocol').addEventListener('click', async () => {
if (!mesh || !protocol) return;
mesh.broadcast({ type: 'protocol_start' });
$('btn-start-protocol').hidden = true;
await protocol.start();
});
/* ─── Retry / again ─── */
$('btn-retry').addEventListener('click', () => {
if (protocol) protocol.reset();
$('coin-area').hidden = true;
$('result-area').hidden = true;
$('btn-start-protocol').hidden = role !== 'host';
$('protocol-text').textContent = 'Bereit zum Münzwurf';
updatePhases('IDLE');
});
/* ─── Result screen → play again ─── */
$('btn-play-again').addEventListener('click', () => {
if (protocol) protocol.reset();
$('coin-area').hidden = true;
$('result-area').hidden = true;
setupGame(false);
});
/* ─── Back to start ─── */
$('btn-back-start').addEventListener('click', () => {
if (mesh) { mesh.destroy(); mesh = null; }
protocol = null;
camStop();
show('start');
});
})();

61
crypto.js Normal file
View file

@ -0,0 +1,61 @@
function generatePeerId() {
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
async function generateSecret() {
const secret = new Uint8Array(32);
crypto.getRandomValues(secret);
return secret;
}
async function commit(secret) {
const hash = await crypto.subtle.digest('SHA-256', secret);
return new Uint8Array(hash);
}
function arraysEqual(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff === 0;
}
function combine(secrets) {
const result = new Uint8Array(32);
for (const secret of secrets) {
for (let i = 0; i < 32; i++) {
result[i] ^= secret[i];
}
}
return result[0] & 1;
}
function bufToBase64(buf) {
const bytes = new Uint8Array(buf);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64ToBuf(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function hexToBuf(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}

118
index.html Normal file
View file

@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>randomp2p Münzwurf</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- SCREEN: START -->
<section id="screen-start" class="screen active">
<div class="screen-inner">
<h1>randomp2p</h1>
<p class="subtitle">Vertrauensloser Münzwurf mit Freunden</p>
<div class="btn-group">
<button id="btn-create" class="btn-primary">Raum erstellen</button>
<button id="btn-join" class="btn-secondary">Raum beitreten</button>
</div>
</div>
</section>
<!-- SCREEN: HOST LOBBY -->
<section id="screen-host" class="screen">
<div class="screen-inner">
<h2>Dein Raum</h2>
<div class="qr-section">
<div id="qr-host"></div>
<p class="hint">Scannen lassen, um beizutreten</p>
</div>
<div id="host-players" class="player-section">
<h3>Spieler</h3>
<ul id="host-player-list" class="player-list"></ul>
</div>
<div id="host-scan-answer">
<button id="btn-scan-answer" class="btn-secondary">Antwort-QR scannen</button>
<video id="host-camera" autoplay playsinline muted></video>
<canvas id="host-camera-canvas" hidden></canvas>
<p id="host-scan-status"></p>
</div>
<button id="btn-start-game" class="btn-primary" disabled>Münzwurf starten</button>
</div>
</section>
<!-- SCREEN: JOIN LOBBY -->
<section id="screen-join" class="screen">
<div class="screen-inner">
<h2>Raum beitreten</h2>
<div class="camera-section">
<video id="join-camera" autoplay playsinline muted></video>
<canvas id="join-camera-canvas" hidden></canvas>
<p id="join-scan-status">QR-Code scannen...</p>
</div>
<div id="join-answer-section" hidden>
<p>Antwort-QR für den Host:</p>
<div id="qr-answer"></div>
<p class="hint">Zeige diesen QR-Code dem Host</p>
</div>
<ul id="join-player-list" class="player-list"></ul>
</div>
</section>
<!-- SCREEN: GAME -->
<section id="screen-game" class="screen">
<div class="screen-inner">
<h2>Münzwurf</h2>
<div id="game-players" class="player-section">
<ul id="game-player-list" class="player-list"></ul>
</div>
<div id="protocol-status" class="protocol-status">
<div class="phase" id="phase-ready">Bereit</div>
<div class="phase" id="phase-commit">Commit</div>
<div class="phase" id="phase-reveal">Reveal</div>
<div class="phase" id="phase-verify">Verifikation</div>
<div class="phase" id="phase-done">Ergebnis</div>
</div>
<div id="protocol-text" class="protocol-text">Warte auf Start...</div>
<div id="coin-area" hidden>
<div id="coin" class="coin">
<div class="coin-inner">
<div class="coin-face front">K</div>
<div class="coin-face back">Z</div>
</div>
</div>
</div>
<div id="result-area" hidden>
<h3 id="result-text"></h3>
<button id="btn-retry" class="btn-primary">Nochmal</button>
</div>
<button id="btn-start-protocol" class="btn-primary">Münzwurf starten</button>
</div>
</section>
<!-- SCREEN: RESULT OVERLAY -->
<section id="screen-result" class="screen">
<div class="screen-inner">
<div id="result-coin" class="coin large">
<div class="coin-inner">
<div class="coin-face front">K</div>
<div class="coin-face back">Z</div>
</div>
</div>
<h2 id="result-title"></h2>
<p id="result-detail"></p>
<button id="btn-play-again" class="btn-primary">Nochmal spielen</button>
<button id="btn-back-start" class="btn-secondary">Zurück zum Start</button>
</div>
</section>
<script src="https://unpkg.com/simple-peer@9.11.1/simplepeer.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js"></script>
<script src="crypto.js"></script>
<script src="p2p.js"></script>
<script src="protocol.js"></script>
<script src="app.js"></script>
</body>
</html>

217
p2p.js Normal file
View file

@ -0,0 +1,217 @@
class MeshNet {
constructor() {
this.myPeerId = generatePeerId();
this.connections = new Map();
this.isHost = false;
this._pendingPeer = null;
this._currentQRData = null;
this.onpeerconnect = null;
this.onpeerdisconnect = null;
this.ondata = null;
this.onqrupdate = null;
this.onanswerready = null;
}
createRoom() {
this.isHost = true;
this._createPendingPeer();
}
_createPendingPeer() {
if (this._pendingPeer) {
try { this._pendingPeer.peer.destroy(); } catch (e) {}
}
const peer = new SimplePeer({ initiator: true, trickle: false });
let signalSent = false;
let connected = false;
peer.on('signal', signal => {
if (signalSent) return;
signalSent = true;
this._currentQRData = JSON.stringify({ v: 1, id: this.myPeerId, s: signal });
if (this.onqrupdate) this.onqrupdate(this._currentQRData);
});
peer.on('connect', () => {
connected = true;
peer.send(JSON.stringify({ type: 'identity', peerId: this.myPeerId }));
});
peer.on('data', data => {
try {
const msg = JSON.parse(data.toString());
this._handleMessage(msg, peer);
} catch (e) {
console.error('p2p data parse error:', e);
}
});
peer.on('close', () => {
this._cleanupPeer(peer);
});
peer.on('error', err => {
console.error('p2p error:', err);
});
this._pendingPeer = { peer, signalSent, connected, peerId: null };
}
feedAnswer(answerStr) {
if (!this._pendingPeer || this._pendingPeer.signalReceived) return;
try {
const data = JSON.parse(answerStr);
this._pendingPeer.peerId = data.id;
this._pendingPeer.peer.signal(data.s);
this._pendingPeer.signalReceived = true;
} catch (e) {
console.error('feedAnswer error:', e);
}
}
joinFromQR(qrContent) {
const data = JSON.parse(qrContent);
const remoteId = data.id;
const offer = data.s;
const peer = new SimplePeer({ initiator: false, trickle: false });
let answerSent = false;
let connected = false;
peer.on('signal', signal => {
if (answerSent) return;
answerSent = true;
const answerData = JSON.stringify({
v: 1, id: this.myPeerId, s: signal, to: remoteId
});
if (this.onanswerready) this.onanswerready(answerData);
});
peer.on('connect', () => {
connected = true;
this.connections.set(remoteId, peer);
peer.send(JSON.stringify({ type: 'identity', peerId: this.myPeerId }));
if (this.onpeerconnect) this.onpeerconnect(remoteId);
});
peer.on('data', data => {
try {
const msg = JSON.parse(data.toString());
this._handleMessage(msg, peer);
} catch (e) {
console.error('p2p data parse error:', e);
}
});
peer.on('close', () => {
this._cleanupPeer(peer);
});
peer.on('error', err => {
console.error('p2p error:', err);
});
peer.signal(offer);
this.connections.set(remoteId, peer);
this._pendingPeer = { peer, peerId: remoteId, connected };
}
_handleMessage(msg, peer) {
if (msg.type === 'identity') {
const existing = this._findPeerId(peer);
if (existing && existing !== msg.peerId) {
this.connections.delete(existing);
}
this.connections.set(msg.peerId, peer);
if (this._pendingPeer && this._pendingPeer.peer === peer) {
this._pendingPeer.peerId = msg.peerId;
this._pendingPeer.connected = true;
if (this.onpeerconnect) this.onpeerconnect(msg.peerId);
if (this.isHost) {
setTimeout(() => this._createPendingPeer(), 300);
}
} else {
if (this.onpeerconnect) this.onpeerconnect(msg.peerId);
}
return;
}
if (msg.type === 'signal_relay') {
const target = this.connections.get(msg.to);
if (target) {
target.send(JSON.stringify({
type: 'signal_relayed',
from: msg.from,
signal: msg.signal
}));
}
return;
}
if (this.ondata) {
const peerId = this._findPeerId(peer) || 'unknown';
this.ondata(peerId, msg);
}
}
_findPeerId(peer) {
for (const [id, p] of this.connections) {
if (p === peer) return id;
}
return null;
}
_cleanupPeer(peer) {
const peerId = this._findPeerId(peer);
if (peerId) {
this.connections.delete(peerId);
if (this.onpeerdisconnect) this.onpeerdisconnect(peerId);
}
if (this._pendingPeer && this._pendingPeer.peer === peer) {
this._pendingPeer = null;
}
}
broadcast(msg) {
const str = JSON.stringify(msg);
for (const [id, peer] of this.connections) {
try { peer.send(str); } catch (e) { console.error('broadcast to', id, 'failed:', e); }
}
}
sendTo(peerId, msg) {
const peer = this.connections.get(peerId);
if (peer) {
try { peer.send(JSON.stringify(msg)); } catch (e) { console.error('sendTo', peerId, 'failed:', e); }
}
}
relaySignal(from, to, signal) {
const target = this.connections.get(to);
if (target) {
target.send(JSON.stringify({ type: 'signal_relay', from, to, signal }));
}
}
getPeerIds() {
return Array.from(this.connections.keys());
}
getCount() {
return this.connections.size;
}
destroy() {
for (const peer of this.connections.values()) {
try { peer.destroy(); } catch (e) {}
}
this.connections.clear();
if (this._pendingPeer) {
try { this._pendingPeer.peer.destroy(); } catch (e) {}
this._pendingPeer = null;
}
}
}

109
protocol.js Normal file
View file

@ -0,0 +1,109 @@
class CoinFlipProtocol {
constructor(mesh) {
this.mesh = mesh;
this.state = 'IDLE';
this.secret = null;
this.commits = new Map();
this.reveals = new Map();
this.result = null;
this.totalPeers = 0;
this.onstatechange = null;
this.onprogress = null;
this.oncomplete = null;
this.onerror = null;
}
async start() {
if (this.state !== 'IDLE') return;
this.state = 'STARTING';
this.totalPeers = this.mesh.getCount() + 1;
this.secret = await generateSecret();
const myCommit = await commit(this.secret);
this.commits.set(this.mesh.myPeerId, myCommit);
if (this.onprogress) this.onprogress('commit', 1, this.totalPeers);
if (this.onstatechange) this.onstatechange('COMMITTING');
this.mesh.broadcast({ type: 'protocol', phase: 'commit', data: bufToBase64(myCommit) });
this.state = 'WAITING_FOR_COMMITS';
if (this.onstatechange) this.onstatechange('WAITING_FOR_COMMITS');
this._checkCommits();
}
handleMessage(peerId, msg) {
if (msg.type !== 'protocol') return;
if (msg.phase === 'commit' && (this.state === 'WAITING_FOR_COMMITS' || this.state === 'COMMITTING' || this.state === 'STARTING')) {
if (!this.commits.has(peerId)) {
this.commits.set(peerId, base64ToBuf(msg.data));
if (this.onprogress) this.onprogress('commit', this.commits.size, this.totalPeers);
this._checkCommits();
}
}
if (msg.phase === 'reveal' && (this.state === 'WAITING_FOR_REVEALS' || this.state === 'REVEALING')) {
if (!this.reveals.has(peerId)) {
this.reveals.set(peerId, base64ToBuf(msg.data));
if (this.onprogress) this.onprogress('reveal', this.reveals.size, this.totalPeers);
this._checkReveals();
}
}
}
_checkCommits() {
if (this.state !== 'WAITING_FOR_COMMITS') return;
if (this.commits.size >= this.totalPeers) {
this._allCommitsReceived();
}
}
async _allCommitsReceived() {
this.state = 'REVEALING';
if (this.onstatechange) this.onstatechange('REVEALING');
this.mesh.broadcast({ type: 'protocol', phase: 'reveal', data: bufToBase64(this.secret) });
this.reveals.set(this.mesh.myPeerId, this.secret);
if (this.onprogress) this.onprogress('reveal', 1, this.totalPeers);
this.state = 'WAITING_FOR_REVEALS';
if (this.onstatechange) this.onstatechange('WAITING_FOR_REVEALS');
this._checkReveals();
}
_checkReveals() {
if (this.state !== 'WAITING_FOR_REVEALS') return;
if (this.reveals.size >= this.totalPeers) {
this._allRevealsReceived();
}
}
async _allRevealsReceived() {
this.state = 'VERIFYING';
if (this.onstatechange) this.onstatechange('VERIFYING');
for (const [peerId, secret] of this.reveals) {
if (peerId === this.mesh.myPeerId) continue;
const commitment = this.commits.get(peerId);
if (!commitment) {
if (this.onerror) this.onerror(`Fehler: Kein Commit von ${peerId}`);
return;
}
const expected = await commit(secret);
if (!arraysEqual(expected, commitment)) {
if (this.onerror) this.onerror(`Betrug erkannt! ${peerId} hat gefälscht.`);
return;
}
}
this.state = 'COMPLETE';
this.result = combine(Array.from(this.reveals.values()));
if (this.onstatechange) this.onstatechange('COMPLETE');
if (this.oncomplete) this.oncomplete(this.result);
}
reset() {
this.state = 'IDLE';
this.secret = null;
this.commits.clear();
this.reveals.clear();
this.result = null;
this.totalPeers = 0;
}
}

330
style.css Normal file
View file

@ -0,0 +1,330 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #0d1117;
--bg-card: #161b22;
--bg-hover: #1c2333;
--text: #c9d1d9;
--text-dim: #8b949e;
--accent: #238636;
--accent-hover: #2ea043;
--border: #30363d;
--gold: #f0c040;
--silver: #c0c0c0;
--radius: 12px;
--shadow: 0 4px 24px rgba(0,0,0,0.4);
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
/* Screens */
.screen {
display: none;
height: 100%;
align-items: center;
justify-content: center;
padding: 20px;
}
.screen.active {
display: flex;
}
.screen-inner {
width: 100%;
max-width: 420px;
text-align: center;
}
h1 {
font-size: 2rem;
font-weight: 800;
background: linear-gradient(135deg, var(--gold), #e0a030);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px;
}
h2 {
font-size: 1.4rem;
margin-bottom: 16px;
}
h3 {
font-size: 1rem;
color: var(--text-dim);
margin-bottom: 8px;
}
.subtitle {
color: var(--text-dim);
margin-bottom: 32px;
}
/* Buttons */
.btn-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.btn-primary, .btn-secondary {
padding: 14px 24px;
border: none;
border-radius: var(--radius);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--bg-hover);
}
/* QR section */
.qr-section {
background: #fff;
border-radius: var(--radius);
padding: 16px;
display: inline-block;
margin-bottom: 16px;
}
.qr-section canvas, .qr-section img {
display: block;
margin: 0 auto;
}
.hint {
color: var(--text-dim);
font-size: 0.85rem;
margin-top: 8px;
}
/* Player list */
.player-section {
margin: 16px 0;
}
.player-list {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.player-chip {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 20px;
padding: 6px 16px;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 6px;
}
.player-chip .dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.player-chip .dot.online {
background: var(--accent);
}
.player-chip .dot.waiting {
background: #f0c040;
}
.player-chip.self {
border-color: var(--accent);
}
/* Camera section */
.camera-section {
position: relative;
margin-bottom: 16px;
}
.camera-section video {
width: 100%;
max-width: 320px;
border-radius: var(--radius);
border: 2px solid var(--border);
}
/* Protocol status */
.protocol-status {
display: flex;
justify-content: center;
gap: 4px;
margin: 20px 0;
}
.phase {
padding: 6px 12px;
border-radius: 20px;
font-size: 0.75rem;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-dim);
transition: all 0.3s;
}
.phase.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.phase.done {
background: #1a4a1a;
border-color: var(--accent);
color: var(--accent);
}
.protocol-text {
margin: 12px 0;
color: var(--text-dim);
font-size: 0.9rem;
}
/* Coin flip animation */
.coin {
width: 120px;
height: 120px;
margin: 24px auto;
perspective: 600px;
cursor: default;
}
.coin.large {
width: 160px;
height: 160px;
}
.coin-inner {
width: 100%;
height: 100%;
position: relative;
transform-style: preserve-3d;
transition: transform 1.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.coin-face {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
backface-visibility: hidden;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
font-weight: 900;
color: #333;
}
.coin.large .coin-face {
font-size: 3.5rem;
}
.coin-face.front {
background: radial-gradient(circle at 40% 35%, #ffe066, #f0c040 50%, #c89020);
border: 4px solid #b8860b;
transform: rotateY(0deg);
}
.coin-face.back {
background: radial-gradient(circle at 40% 35%, #e8e8e8, #c0c0c0 50%, #909090);
border: 4px solid #707070;
transform: rotateY(180deg);
}
.coin.flipping .coin-inner {
animation: coinFlip 1.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes coinFlip {
0% { transform: rotateY(0deg); }
60% { transform: rotateY(720deg); }
80% { transform: rotateY(800deg); }
100% { transform: rotateY(720deg); }
}
.coin.result-kopf .coin-inner {
transform: rotateY(0deg);
}
.coin.result-zahl .coin-inner {
transform: rotateY(180deg);
}
/* Result area */
#result-area {
margin-top: 24px;
}
#result-text {
font-size: 2rem;
margin-bottom: 16px;
}
#result-title {
font-size: 3rem;
margin: 24px 0 8px;
}
#result-detail {
color: var(--text-dim);
margin-bottom: 24px;
font-size: 0.9rem;
}
/* Host camera (scan answer) */
#host-scan-answer {
margin: 12px 0;
}
#host-camera {
width: 100%;
max-width: 240px;
border-radius: var(--radius);
display: none;
margin: 8px auto;
}
#host-camera.active {
display: block;
}
#host-scan-status {
color: var(--text-dim);
font-size: 0.85rem;
}
/* Join answer QR */
#join-answer-section {
margin: 16px 0;
}
#join-answer-section .qr-section {
background: #fff;
border-radius: var(--radius);
padding: 16px;
display: inline-block;
}
/* Responsive */
@media (max-width: 480px) {
.screen {
padding: 16px;
}
h1 { font-size: 1.6rem; }
.phase {
font-size: 0.65rem;
padding: 4px 8px;
}
.coin { width: 100px; height: 100px; }
.coin.large { width: 140px; height: 140px; }
.coin-face { font-size: 2rem; }
.coin.large .coin-face { font-size: 3rem; }
}
/* Utility */
.hidden { display: none !important; }