Social Change – Online Multiplayer
by GoldenCobra39985 lines35.3 KB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Social Change – Online Multiplayer</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Segoe UI', Roboto, system-ui, sans-serif;
background: #0f111a;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
color: #eaeef5;
}
.container {
max-width: 900px;
width: 100%;
background: #181c2b;
border-radius: 2.4rem;
padding: 2rem;
box-shadow: 0 30px 60px -16px rgba(0,0,0,0.8), inset 0 1px 0 rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.05);
}
/* lobby */
.lobby {
display: flex;
flex-direction: column;
gap: 1.5rem;
align-items: center;
padding: 1.5rem 0;
}
.lobby h1 { font-size: 2rem; font-weight: 700; color: #f0f3fa; }
.lobby p { color: #8e99c4; max-width: 400px; text-align: center; }
.lobby .actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
}
.lobby .btn {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.08);
color: #d0d8ee;
font-weight: 600;
font-size: 1rem;
padding: 0.8rem 2rem;
border-radius: 60px;
cursor: pointer;
transition: all 0.2s;
min-width: 160px;
}
.lobby .btn:hover { background: rgba(255,255,255,0.12); transform: scale(1.02); }
.lobby .btn.primary {
background: #facc15;
border-color: #facc15;
color: #121826;
box-shadow: 0 8px 24px -10px rgba(250,204,21,0.25);
}
.lobby .btn.primary:hover { background: #fde047; }
.lobby .input-group {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
justify-content: center;
}
.lobby .input-group input {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 60px;
padding: 0.7rem 1.2rem;
font-size: 1rem;
color: #eaeef5;
outline: none;
width: 200px;
}
.lobby .input-group input:focus { border-color: #facc15; }
.lobby .input-group input::placeholder { color: #5a6490; }
.lobby .status { color: #f87171; font-size: 0.9rem; min-height: 1.4rem; }
/* game header */
.game-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.6rem;
margin-bottom: 1.2rem;
}
.game-header .title { font-weight: 700; font-size: 1.4rem; color: #f0f3fa; }
.game-header .room-code {
background: rgba(255,255,255,0.06);
padding: 0.3rem 1rem;
border-radius: 40px;
font-size: 0.9rem;
color: #facc15;
border: 1px solid rgba(250,204,21,0.15);
}
.game-header .room-code .copy-btn {
background: none;
border: none;
color: #8e99c4;
cursor: pointer;
margin-left: 0.5rem;
font-size: 0.8rem;
}
.game-header .room-code .copy-btn:hover { color: #eaeef5; }
.game-header .leave-btn {
background: rgba(248,113,113,0.1);
border: 1px solid rgba(248,113,113,0.15);
color: #fca5a5;
padding: 0.3rem 1.2rem;
border-radius: 40px;
cursor: pointer;
font-size: 0.8rem;
transition: 0.2s;
}
.game-header .leave-btn:hover { background: rgba(248,113,113,0.2); }
/* player strip */
.player-strip {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-bottom: 1.5rem;
padding: 0.2rem 0;
}
.player-pill {
background: rgba(255,255,255,0.05);
padding: 0.4rem 1.2rem;
border-radius: 60px;
font-size: 0.85rem;
font-weight: 500;
color: #8e99c4;
border: 1px solid transparent;
transition: all 0.25s;
display: flex;
align-items: center;
gap: 0.4rem;
}
.player-pill.active {
background: rgba(250,204,21,0.15);
border-color: #facc15;
color: #facc15;
}
.player-pill.answered {
background: rgba(74,222,128,0.08);
border-color: rgba(74,222,128,0.2);
color: #86efac;
}
/* card */
.card {
background: #f2f5fe;
background-image: radial-gradient(circle at 20% 25%, #ffffff, #e4e9f7);
border-radius: 2rem;
padding: 1.8rem 2rem;
margin-bottom: 1.2rem;
box-shadow: 0 16px 40px -12px rgba(0,0,0,0.5);
border: 1px solid rgba(255,255,255,0.2);
}
.card .q-number {
font-size: 0.75rem;
font-weight: 600;
color: #4b5580;
background: rgba(0,0,0,0.04);
padding: 0.15rem 0.9rem;
border-radius: 40px;
display: inline-block;
margin-bottom: 0.8rem;
}
.card .q-text {
font-size: 1.35rem;
line-height: 1.5;
font-weight: 500;
color: #121826;
}
.card .q-text::before { content: '\201C'; font-size: 2.4rem; font-weight: 300; color: #6b7ab0; margin-right: 0.2rem; opacity: 0.5; }
.card .q-text::after { content: '\201D'; font-size: 2.4rem; font-weight: 300; color: #6b7ab0; margin-left: 0.2rem; opacity: 0.5; }
.card .q-meta {
margin-top: 1rem;
font-size: 0.75rem;
color: #4b5580;
border-top: 1px solid rgba(0,0,0,0.05);
padding-top: 0.8rem;
display: flex;
justify-content: space-between;
}
/* answer section */
.answer-section {
background: rgba(255,255,255,0.03);
border-radius: 1.4rem;
padding: 1.2rem 1.4rem;
margin-bottom: 1.2rem;
border: 1px solid rgba(255,255,255,0.05);
}
.answer-section .label {
font-size: 0.8rem;
font-weight: 600;
color: #8e99c4;
margin-bottom: 0.6rem;
}
.answer-row {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.answer-row input {
flex: 1;
min-width: 180px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 60px;
padding: 0.7rem 1.2rem;
font-size: 0.95rem;
color: #eaeef5;
outline: none;
}
.answer-row input:focus { border-color: #facc15; background: rgba(255,255,255,0.08); }
.answer-row input:disabled { opacity: 0.4; cursor: not-allowed; }
.answer-row .btn-submit {
background: #facc15;
border: none;
color: #121826;
font-weight: 700;
font-size: 0.9rem;
padding: 0.7rem 1.8rem;
border-radius: 60px;
cursor: pointer;
transition: 0.2s;
white-space: nowrap;
}
.answer-row .btn-submit:hover:not(:disabled) { background: #fde047; transform: scale(1.02); }
.answer-row .btn-submit:disabled { opacity: 0.4; cursor: not-allowed; }
.answer-status { font-size: 0.8rem; color: #86efac; margin-top: 0.5rem; min-height: 1.4rem; }
/* revealed answers */
.revealed-section {
margin-bottom: 1.2rem;
}
.revealed-section .section-title {
font-size: 0.8rem;
font-weight: 600;
color: #8e99c4;
margin-bottom: 0.6rem;
}
.revealed-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 200px;
overflow-y: auto;
padding-right: 0.2rem;
}
.revealed-list::-webkit-scrollbar { width: 4px; }
.revealed-list::-webkit-scrollbar-track { background: rgba(255,255,255,0.03); border-radius: 10px; }
.revealed-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 10px; }
.revealed-item {
background: rgba(255,255,255,0.04);
border-radius: 0.8rem;
padding: 0.6rem 1rem;
border-left: 3px solid rgba(250,204,21,0.3);
animation: fadeIn 0.3s ease;
}
.revealed-item .r-player { font-weight: 600; font-size: 0.8rem; color: #facc15; }
.revealed-item .r-answer { font-size: 0.95rem; color: #d0d8ee; margin-top: 0.1rem; word-break: break-word; }
.revealed-item .r-question { font-size: 0.7rem; color: #6a74a0; margin-top: 0.2rem; opacity: 0.7; font-style: italic; }
.revealed-empty { color: #4b5580; font-size: 0.85rem; padding: 0.4rem 0; }
@keyframes fadeIn { 0% { opacity:0; transform:translateY(8px); } 100% { opacity:1; transform:translateY(0); } }
/* controls */
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
justify-content: center;
margin-top: 0.2rem;
}
.controls .btn-action {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.08);
color: #d0d8ee;
font-weight: 600;
font-size: 0.85rem;
padding: 0.6rem 1.6rem;
border-radius: 60px;
cursor: pointer;
transition: 0.2s;
flex: 1 1 auto;
min-width: 120px;
text-align: center;
}
.controls .btn-action:hover:not(:disabled) { background: rgba(255,255,255,0.12); transform: scale(1.02); }
.controls .btn-action:disabled { opacity: 0.3; cursor: not-allowed; }
.controls .btn-action.primary {
background: #facc15;
border-color: #facc15;
color: #121826;
font-weight: 700;
box-shadow: 0 8px 24px -10px rgba(250,204,21,0.25);
}
.controls .btn-action.primary:hover:not(:disabled) { background: #fde047; }
.controls .btn-action.danger {
border-color: rgba(248,113,113,0.2);
color: #fca5a5;
}
.controls .btn-action.danger:hover:not(:disabled) { background: rgba(248,113,113,0.1); }
/* responsive */
@media (max-width: 640px) {
.container { padding: 1.2rem; border-radius: 1.6rem; }
.card { padding: 1.2rem; }
.card .q-text { font-size: 1.05rem; }
.card .q-text::before, .card .q-text::after { font-size: 1.8rem; }
.lobby h1 { font-size: 1.6rem; }
.lobby .btn { min-width: 120px; font-size: 0.9rem; padding: 0.6rem 1.2rem; }
}
</style>
</head>
<body>
<div class="container" id="app">
<!-- LOBBY -->
<div id="lobby" class="lobby">
<h1>Social Change</h1>
<p>Host a private room or join an existing game. All answers are shared live.</p>
<div class="actions">
<button class="btn primary" id="hostBtn">Host Game</button>
<div style="display:flex; flex-direction:column; gap:0.4rem; align-items:center;">
<div class="input-group">
<input type="text" id="joinCodeInput" placeholder="Room code" maxlength="6" style="text-transform:uppercase;">
<button class="btn" id="joinBtn">Join</button>
</div>
<div class="status" id="lobbyStatus"></div>
</div>
</div>
</div>
<!-- GAME UI (hidden until joined/hosted) -->
<div id="gameUI" style="display:none;">
<div class="game-header">
<span class="title">Social Change</span>
<div>
<span class="room-code" id="roomCodeDisplay">Code: <span id="codeSpan">------</span>
<button class="copy-btn" id="copyCodeBtn">Copy</button>
</span>
<button class="leave-btn" id="leaveGameBtn">Leave</button>
</div>
</div>
<div class="player-strip" id="playerStrip"></div>
<div class="card" id="cardDisplay">
<div class="q-number" id="qNumber">#00</div>
<div class="q-text" id="qText">Draw a card to begin</div>
<div class="q-meta">
<span id="qRemaining">deck: 40</span>
<span id="qTurnHint">waiting</span>
</div>
</div>
<div class="answer-section">
<div class="label" id="answerLabel">Your answer</div>
<div class="answer-row">
<input type="text" id="answerInput" placeholder="Type your answer..." maxlength="300" disabled>
<button class="btn-submit" id="submitAnswerBtn" disabled>Submit</button>
</div>
<div class="answer-status" id="answerStatus"></div>
</div>
<div class="revealed-section">
<div class="section-title">Revealed answers</div>
<div class="revealed-list" id="revealedList">
<div class="revealed-empty">No answers yet.</div>
</div>
</div>
<div class="controls">
<button class="btn-action primary" id="drawCardBtn">Draw card</button>
<button class="btn-action" id="nextTurnBtn">Next player</button>
<button class="btn-action danger" id="resetGameBtn">Reset Game</button>
</div>
</div>
</div>
<!-- PeerJS library -->
<script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
<script>
(function() {
"use strict";
// ─── CONSTANTS ──────────────────────────────────────────────────────────
const QUESTION_POOL = [
"When was the last time you changed your mind about something important, and what actually made you switch?",
"If your school suddenly had an extra hour of free time every day, what would you actually do with it—and would it make you happier or just lazier?",
"Be real: do you actually recycle, or do you just throw everything in the same bin and hope for the best?",
"What's a compliment you've received that stuck with you way longer than the person probably expected?",
"If you could trade lives with anyone in your grade for one week, who would it be and why?",
"Have you ever pretended to understand a social issue just so you wouldn't look clueless? What was it?",
"Think about the last time you saw someone being treated unfairly. Did you say something, walk away, or freeze? And how did you feel after?",
"If your phone died right now and you couldn't charge it for 24 hours, who in this room would you actually talk to first?",
"What's a word or phrase you overuse that you think makes you sound less smart than you actually are?",
"Is there a teacher or adult in your life who actually gets it? What makes them different from the rest?",
"If you had to give a five-minute speech to the whole school with no prep, what would you even talk about?",
"When you see a viral video of someone doing something kind for a stranger, does it inspire you or make you roll your eyes?",
"What's one thing your parents or guardians believe that you're pretty sure you'll never agree with?",
"If a friend told you a secret that made you think less of them, would you tell them honestly or just let the friendship fade?",
"Think about the last argument you witnessed online or in person. Did anyone actually listen, or was everyone just waiting for their turn to talk?",
"What's a 'small' thing someone did for you that actually changed your whole mood that day?",
"If your neighborhood had a giant billboard that could say one message to everyone, what would you put on it?",
"Have you ever been the only person in a room who disagreed with everyone else? What did you do?",
"What's something you secretly judge people for that you know you probably shouldn't?",
"If you woke up tomorrow and everyone in the world was exactly like you—same personality, same habits—would the world be better or worse?",
"What's one rule at this school (or in your house) that you secretly think is totally pointless—and what would you replace it with?",
"Be honest: when was the last time you actually read a news article instead of just the headline, and what made you click it?",
"If you had an extra $20 right now, are you buying yourself a treat, grabbing food for a friend, or dropping it in a donation box? No judgment, just facts.",
"Have you ever laughed at a joke that you knew was kind of messed up, just to fit in? What did you actually want to say?",
"If your Instagram/TikTok feed suddenly only showed posts from your local neighborhood (not celebrities), what do you think you'd see?",
"What's a 'tiny' annoyance in your daily life that you think actually points to a much bigger problem in the world?",
"If you could snap your fingers and make one group of people listen to another group of people for five minutes, who would they be?",
"Do you actually check where your clothes are made when you buy them, or do you just grab what looks cool? What would it take for you to care?",
"Think about your group chat. If one of you said something super offensive, would someone actually call it out, or would everyone just leave it on read?",
"What's a cause you care about but literally do nothing for? No shame—why do you think that is?",
"If you could change one thing about how your school handles conflict, what would it be and why?",
"What's a belief you held strongly a year ago that you've since softened on?",
"If your friends all decided to volunteer somewhere together, where would you want to go and what would you do?",
"What's the most recent thing you learned that actually changed how you see the world?",
"If you had to swap roles with a teacher for a day, what would you teach and how?",
"What's something you've never told anyone because you're afraid they'd judge you?",
"If you could make one rule that everyone in your school had to follow, what would it be?",
"What's a moment when you felt truly heard by someone? What made it different?",
"If you could send a one-sentence message to everyone in your generation, what would it say?",
"What's a question you wish more people would ask you?"
];
// ─── DOM refs ──────────────────────────────────────────────────────────
const lobby = document.getElementById('lobby');
const gameUI = document.getElementById('gameUI');
const hostBtn = document.getElementById('hostBtn');
const joinBtn = document.getElementById('joinBtn');
const joinCodeInput = document.getElementById('joinCodeInput');
const lobbyStatus = document.getElementById('lobbyStatus');
const codeSpan = document.getElementById('codeSpan');
const copyCodeBtn = document.getElementById('copyCodeBtn');
const leaveGameBtn = document.getElementById('leaveGameBtn');
const playerStrip = document.getElementById('playerStrip');
const qNumber = document.getElementById('qNumber');
const qText = document.getElementById('qText');
const qRemaining = document.getElementById('qRemaining');
const qTurnHint = document.getElementById('qTurnHint');
const answerInput = document.getElementById('answerInput');
const submitAnswerBtn = document.getElementById('submitAnswerBtn');
const answerStatus = document.getElementById('answerStatus');
const answerLabel = document.getElementById('answerLabel');
const revealedList = document.getElementById('revealedList');
const drawCardBtn = document.getElementById('drawCardBtn');
const nextTurnBtn = document.getElementById('nextTurnBtn');
const resetGameBtn = document.getElementById('resetGameBtn');
const roomCodeDisplay = document.getElementById('roomCodeDisplay');
// ─── PEER / STATE ──────────────────────────────────────────────────────
let peer = null; // Peer instance
let isHost = false;
let roomCode = '';
let myPeerId = '';
let connections = {}; // peerId -> DataConnection
let gameState = null; // full state object (host only)
let myPlayerName = '';
// ─── HELPERS ──────────────────────────────────────────────────────────
function generateRoomCode() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let code = '';
for (let i=0; i<6; i++) code += chars.charAt(Math.floor(Math.random() * chars.length));
return code;
}
function shuffleArray(arr) {
for (let i = arr.length-1; i>0; i--) {
const j = Math.floor(Math.random() * (i+1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
function buildDeck(used) {
const available = QUESTION_POOL.filter(q => !used.includes(q));
if (available.length === 0) return shuffleArray([...QUESTION_POOL]);
return shuffleArray([...available]);
}
function escapeHtml(text) {
const d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
// ─── RENDER FUNCTIONS ──────────────────────────────────────────────────
function renderGame() {
if (!gameState) return;
const state = gameState;
// Player strip
let stripHtml = '';
state.players.forEach((p, idx) => {
const isActive = (idx === state.currentPlayerIndex);
const hasAnswered = state.revealedAnswers.some(r => r.playerName === p.name && r.question === state.currentQuestion);
let cls = 'player-pill';
if (isActive) cls += ' active';
if (hasAnswered) cls += ' answered';
stripHtml += `<div class="${cls}">${escapeHtml(p.name)}${isActive ? ' ●' : ''}${hasAnswered ? ' ✓' : ''}</div>`;
});
playerStrip.innerHTML = stripHtml || '<span style="color:#4b5580;font-size:0.85rem;">No players</span>';
// Card
if (state.currentQuestion) {
qNumber.textContent = '#' + String(state.currentQuestionIndex+1).padStart(2, '0');
qText.textContent = state.currentQuestion;
qRemaining.textContent = 'deck: ' + state.deck.length + ' | used: ' + state.usedQuestions.length;
const cur = state.players[state.currentPlayerIndex];
qTurnHint.textContent = cur ? cur.name + '\'s turn' : 'waiting';
} else {
qNumber.textContent = '#00';
qText.textContent = 'Draw a card to begin';
qRemaining.textContent = 'deck: ' + state.deck.length;
qTurnHint.textContent = 'waiting';
}
// Revealed answers
if (state.revealedAnswers.length === 0) {
revealedList.innerHTML = '<div class="revealed-empty">No answers yet.</div>';
} else {
let html = '';
const reversed = [...state.revealedAnswers].reverse();
for (const r of reversed) {
html += `
<div class="revealed-item">
<div class="r-player">${escapeHtml(r.playerName)}</div>
<div class="r-answer">${escapeHtml(r.answer)}</div>
<div class="r-question">${escapeHtml(r.question)}</div>
</div>
`;
}
revealedList.innerHTML = html;
revealedList.scrollTop = 0;
}
// Answer section
const currentPlayer = state.players[state.currentPlayerIndex];
const isMyTurn = currentPlayer && currentPlayer.name === myPlayerName;
const alreadyAnswered = state.revealedAnswers.some(r => r.playerName === myPlayerName && r.question === state.currentQuestion);
const allAnswered = state.players.length > 0 && state.players.every(p =>
state.revealedAnswers.some(r => r.playerName === p.name && r.question === state.currentQuestion)
);
if (!state.currentQuestion) {
answerInput.disabled = true;
submitAnswerBtn.disabled = true;
answerLabel.textContent = 'No question';
answerStatus.textContent = '';
} else if (alreadyAnswered) {
answerInput.disabled = true;
submitAnswerBtn.disabled = true;
answerLabel.textContent = myPlayerName + ' (answered)';
answerStatus.textContent = 'You have already answered this question.';
} else if (allAnswered) {
answerInput.disabled = true;
submitAnswerBtn.disabled = true;
answerLabel.textContent = 'All answered';
answerStatus.textContent = 'Everyone has answered. Draw a new card or go to next player.';
} else if (isMyTurn) {
answerInput.disabled = false;
submitAnswerBtn.disabled = false;
answerLabel.textContent = myPlayerName + '\'s answer';
answerStatus.textContent = '';
answerInput.focus();
} else {
answerInput.disabled = true;
submitAnswerBtn.disabled = true;
const turnPlayer = state.players[state.currentPlayerIndex];
answerLabel.textContent = (turnPlayer ? turnPlayer.name : 'Someone') + '\'s turn';
answerStatus.textContent = 'Wait for your turn.';
}
// Update code display
codeSpan.textContent = roomCode;
}
// ─── PEER / NETWORK ────────────────────────────────────────────────────
// Host: creates a Peer with the room code as ID
function hostGame() {
const code = generateRoomCode();
roomCode = code;
isHost = true;
// Create peer with the code as ID
peer = new Peer(code, {
debug: 2
});
peer.on('open', (id) => {
myPeerId = id;
// Prompt for name
const name = prompt('Enter your name (as host):', 'Host');
if (name && name.trim()) {
myPlayerName = name.trim();
} else {
myPlayerName = 'Host';
}
// Initialize game state
gameState = {
players: [{ name: myPlayerName, id: myPeerId }],
currentPlayerIndex: 0,
currentQuestion: null,
currentQuestionIndex: -1,
deck: shuffleArray([...QUESTION_POOL]),
usedQuestions: [],
revealedAnswers: []
};
showGameUI();
renderGame();
lobbyStatus.textContent = '';
});
peer.on('connection', (conn) => {
// New joiner
connections[conn.peer] = conn;
conn.on('data', (data) => {
handleMessage(data, conn.peer);
});
conn.on('close', () => {
// Remove player
if (gameState) {
const idx = gameState.players.findIndex(p => p.id === conn.peer);
if (idx !== -1) {
const removed = gameState.players[idx];
gameState.players.splice(idx, 1);
if (gameState.currentPlayerIndex >= gameState.players.length) {
gameState.currentPlayerIndex = 0;
}
// Broadcast updated state
broadcastState();
renderGame();
}
}
delete connections[conn.peer];
});
// Send current state to new joiner
if (gameState) {
conn.send({ type: 'initState', state: gameState });
}
});
peer.on('error', (err) => {
if (err.type === 'unavailable-id') {
// Code taken, retry
lobbyStatus.textContent = 'Room code taken, trying another...';
setTimeout(() => {
peer.destroy();
hostGame();
}, 500);
} else {
lobbyStatus.textContent = 'Error: ' + err.message;
console.error(err);
}
});
}
// Join: connect to host's peer ID
function joinGame(code) {
roomCode = code.toUpperCase();
isHost = false;
// Prompt for name
const name = prompt('Enter your name:', 'Player');
if (!name || !name.trim()) {
lobbyStatus.textContent = 'Name is required.';
return;
}
myPlayerName = name.trim();
// Create a random peer ID
const randomId = 'player-' + Math.random().toString(36).substr(2, 6);
peer = new Peer(randomId, { debug: 2 });
peer.on('open', (id) => {
myPeerId = id;
// Connect to host
const conn = peer.connect(roomCode, { reliable: true });
conn.on('open', () => {
connections[roomCode] = conn;
// We'll receive initState
showGameUI();
lobbyStatus.textContent = '';
// Listen for messages
conn.on('data', (data) => {
handleMessage(data, roomCode);
});
conn.on('close', () => {
lobbyStatus.textContent = 'Disconnected from host.';
delete connections[roomCode];
// Return to lobby?
});
});
});
peer.on('error', (err) => {
if (err.type === 'peer-unavailable') {
lobbyStatus.textContent = 'Room not found. Check the code.';
} else {
lobbyStatus.textContent = 'Error: ' + err.message;
}
console.error(err);
});
}
// Handle incoming messages
function handleMessage(data, senderId) {
if (data.type === 'initState') {
// Joiner receives full state
gameState = data.state;
renderGame();
return;
}
// Only host processes these and broadcasts
if (isHost && gameState) {
switch (data.type) {
case 'drawCard':
drawCardInternal();
break;
case 'submitAnswer': {
const p = gameState.players.find(p => p.name === data.playerName);
if (p) {
gameState.revealedAnswers.push({
playerName: data.playerName,
question: gameState.currentQuestion,
answer: data.answer
});
broadcastState();
renderGame();
}
break;
}
case 'nextTurn':
if (gameState.players.length > 0) {
gameState.currentPlayerIndex = (gameState.currentPlayerIndex + 1) % gameState.players.length;
broadcastState();
renderGame();
}
break;
case 'resetRequest':
// Only host processes reset
resetGameInternal();
break;
}
} else if (!isHost && senderId === roomCode) {
// Joiner receives state updates from host
if (data.type === 'stateUpdate') {
gameState = data.state;
renderGame();
}
}
}
// Host: broadcast state to all connections
function broadcastState() {
if (!isHost) return;
for (const pid in connections) {
const conn = connections[pid];
if (conn && conn.open) {
conn.send({ type: 'stateUpdate', state: gameState });
}
}
renderGame(); // update local
}
// Host: draw a card
function drawCardInternal() {
if (!isHost || !gameState) return;
if (gameState.deck.length === 0) {
gameState.usedQuestions = [];
gameState.deck = shuffleArray([...QUESTION_POOL]);
}
const idx = Math.floor(Math.random() * gameState.deck.length);
const q = gameState.deck.splice(idx, 1)[0];
gameState.usedQuestions.push(q);
gameState.currentQuestion = q;
gameState.currentQuestionIndex = gameState.usedQuestions.length - 1;
// Reset answered status for new question (but we keep old answers as history)
broadcastState();
}
// Host: reset game
function resetGameInternal() {
if (!isHost || !gameState) return;
// Keep players, but reset everything else
gameState.currentPlayerIndex = 0;
gameState.currentQuestion = null;
gameState.currentQuestionIndex = -1;
gameState.deck = shuffleArray([...QUESTION_POOL]);
gameState.usedQuestions = [];
gameState.revealedAnswers = [];
// Clear answer input
answerInput.value = '';
answerStatus.textContent = '';
broadcastState();
renderGame();
}
// ─── UI HELPERS ──────────────────────────────────────────────────────────
function showGameUI() {
lobby.style.display = 'none';
gameUI.style.display = 'block';
}
function showLobby() {
lobby.style.display = 'flex';
gameUI.style.display = 'none';
// Clean up peer
if (peer) {
peer.destroy();
peer = null;
}
connections = {};
gameState = null;
isHost = false;
roomCode = '';
lobbyStatus.textContent = '';
}
// ─── EVENT LISTENERS ──────────────────────────────────────────────────
hostBtn.addEventListener('click', () => {
lobbyStatus.textContent = 'Creating room...';
hostGame();
});
joinBtn.addEventListener('click', () => {
const code = joinCodeInput.value.trim().toUpperCase();
if (!code || code.length < 4) {
lobbyStatus.textContent = 'Please enter a valid room code (4+ characters).';
return;
}
lobbyStatus.textContent = 'Joining...';
joinGame(code);
});
joinCodeInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') joinBtn.click();
});
copyCodeBtn.addEventListener('click', () => {
const text = roomCode;
if (navigator.clipboard) {
navigator.clipboard.writeText(text).catch(() => {});
} else {
// fallback
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
copyCodeBtn.textContent = 'Copied!';
setTimeout(() => copyCodeBtn.textContent = 'Copy', 1500);
});
leaveGameBtn.addEventListener('click', () => {
if (confirm('Leave the game?')) {
showLobby();
}
});
// Game action buttons
drawCardBtn.addEventListener('click', () => {
if (isHost) {
drawCardInternal();
} else {
// Send request to host
if (connections[roomCode] && connections[roomCode].open) {
connections[roomCode].send({ type: 'drawCard' });
}
}
});
submitAnswerBtn.addEventListener('click', () => {
const answer = answerInput.value.trim();
if (!answer) {
answerStatus.textContent = 'Please type an answer.';
return;
}
if (!gameState || !gameState.currentQuestion) return;
// Check if already answered
if (gameState.revealedAnswers.some(r => r.playerName === myPlayerName && r.question === gameState.currentQuestion)) {
answerStatus.textContent = 'You already answered this.';
return;
}
// Send to host
if (isHost) {
// Host can directly submit
gameState.revealedAnswers.push({
playerName: myPlayerName,
question: gameState.currentQuestion,
answer: answer
});
broadcastState();
answerInput.value = '';
answerStatus.textContent = 'Answer submitted!';
} else {
if (connections[roomCode] && connections[roomCode].open) {
connections[roomCode].send({
type: 'submitAnswer',
playerName: myPlayerName,
answer: answer
});
answerInput.value = '';
answerStatus.textContent = 'Answer sent!';
} else {
answerStatus.textContent = 'Not connected to host.';
}
}
});
answerInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !submitAnswerBtn.disabled) {
e.preventDefault();
submitAnswerBtn.click();
}
});
nextTurnBtn.addEventListener('click', () => {
if (isHost) {
if (gameState.players.length > 0) {
gameState.currentPlayerIndex = (gameState.currentPlayerIndex + 1) % gameState.players.length;
broadcastState();
}
} else {
if (connections[roomCode] && connections[roomCode].open) {
connections[roomCode].send({ type: 'nextTurn' });
}
}
});
// Reset button
resetGameBtn.addEventListener('click', () => {
if (!gameState) return;
if (isHost) {
if (confirm('Reset the game? All answers and the current question will be cleared.')) {
resetGameInternal();
}
} else {
// Send reset request to host
if (connections[roomCode] && connections[roomCode].open) {
connections[roomCode].send({ type: 'resetRequest' });
lobbyStatus.textContent = 'Reset requested...';
setTimeout(() => { lobbyStatus.textContent = ''; }, 2000);
} else {
alert('Not connected to host.');
}
}
});
// ─── INIT ──────────────────────────────────────────────────────────────
// Start with lobby visible
showLobby();
// If PeerJS fails to load, show error
if (typeof Peer === 'undefined') {
lobbyStatus.textContent = 'PeerJS library failed to load. Check internet connection.';
}
})();
</script>
</body>
</html>Game Source: Social Change – Online Multiplayer
Creator: GoldenCobra39
Libraries: none
Complexity: complex (985 lines, 35.3 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: social-change-online-multiplayer-goldencobra39" to link back to the original. Then publish at arcadelab.ai/publish.