Initial commit

This commit is contained in:
Cam Spry 2026-03-01 04:47:14 -05:00
commit 6e55209159
3 changed files with 1344 additions and 0 deletions

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "yarn",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"socket.io": "^4.8.3",
"uuid": "^13.0.0"
}
}

837
public/index.html Normal file
View File

@ -0,0 +1,837 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>YARN 2026 Spin Your Story!</title>
<style>
:root {
--bg-main: #4b0082;
--text-beige: #FFFFE4;
--box-bg: #0a0a0a;
--player-green: #00ff88;
--player-yellow: #ffdd00;
--header-bg: #3a0066;
--accent-purple: #6a0dad;
--chat-text: #ffffcc;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
height: 100vh;
background: var(--bg-main);
font-family: system-ui, sans-serif;
color: var(--text-beige);
overflow: hidden;
}
#layout {
height: 100vh;
display: flex;
flex-direction: column;
}
#header {
flex: 0 0 5%;
background: var(--header-bg);
color: white;
font-size: 1.4rem;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 2px solid var(--accent-purple);
letter-spacing: 1px;
}
#main-text {
flex: 0 0 50%;
background: var(--box-bg);
margin: 1.2rem 2rem 0 2rem;
border-radius: 8px;
padding: 1.6rem 2rem;
overflow-y: auto;
font-family: "Times New Roman", Times, serif;
font-size: 1.25rem;
line-height: 1.45;
color: var(--text-beige);
border: 2px solid var(--accent-purple);
}
.story-segment {
display: inline;
}
.story-segment[data-player]::before {
content: attr(data-player) ": ";
font-weight: bold;
color: var(--accent-purple);
display: none;
}
#game-bar {
flex: 0 0 10%;
background: var(--bg-main);
padding: 0.8rem 2rem;
display: flex;
align-items: center;
justify-content: center;
gap: 1.4rem;
flex-wrap: wrap;
}
#game-bar > * {
font-size: 1.1rem;
font-weight: bold;
color: white;
}
#game-bar input[type="text"],
#game-bar input[type="number"] {
background: #1a1a1a;
border: 2px solid var(--accent-purple);
border-radius: 4px;
padding: 0.5rem 1rem;
color: var(--text-beige);
font-size: 1rem;
}
#game-bar button {
background: var(--accent-purple);
border: none;
border-radius: 4px;
padding: 0.6rem 1.2rem;
color: white;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: background 0.2s;
}
#game-bar button:hover {
background: #8a1dbd;
}
#game-bar button:disabled {
background: #444;
cursor: not-allowed;
}
.setting-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.setting-group label {
font-size: 0.9rem;
}
.setting-group input[type="number"] {
width: 80px;
}
#timer {
font-size: 2rem;
color: #ff6666;
min-width: 60px;
text-align: center;
}
#timer.warning {
color: #ff3333;
animation: pulse 0.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
#entry-input {
flex: 1;
max-width: 500px;
min-width: 200px;
}
#voting-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 100%;
overflow-y: auto;
width: 100%;
max-width: 800px;
}
.vote-entry {
display: flex;
align-items: center;
gap: 1rem;
background: #1a1a1a;
padding: 0.5rem 1rem;
border-radius: 4px;
border: 1px solid var(--accent-purple);
}
.vote-entry-text {
flex: 1;
font-size: 0.95rem;
}
.vote-btn {
background: var(--player-green);
color: #000;
border: none;
padding: 0.4rem 1rem;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.vote-btn:disabled {
background: #444;
color: #888;
cursor: not-allowed;
}
#chat-area {
flex: 1;
display: flex;
margin: 0 2rem 1.2rem 2rem;
background: var(--box-bg);
border-radius: 8px;
overflow: hidden;
color: var(--chat-text);
border: 2px solid var(--accent-purple);
}
#players {
width: 260px;
background: #111;
padding: 1rem 0.8rem;
overflow-y: auto;
border-right: 1px solid #333;
}
#players-header {
font-weight: bold;
padding: 0.3rem 1rem 0.8rem;
border-bottom: 1px solid #333;
margin-bottom: 0.5rem;
color: white;
}
.player-row {
display: flex;
justify-content: space-between;
padding: 0.15rem 1rem;
font-family: Arial, Helvetica, sans-serif;
font-size: 1.05rem;
line-height: 1.2;
transition: color 0.3s;
}
.player-row.submitted {
color: var(--player-green);
}
.player-row.pending {
color: var(--player-yellow);
}
.player-row.host::after {
content: " ★";
color: gold;
}
.player-name {
text-align: left;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-score {
text-align: right;
min-width: 70px;
padding-left: 1rem;
}
#chat-and-input {
flex: 1;
display: flex;
flex-direction: column;
}
#chat-messages {
flex: 1;
padding: 1.3rem 1.6rem;
overflow-y: auto;
font-size: 1.1rem;
line-height: 1.4;
}
.chat-message {
margin-bottom: 0.3rem;
}
.chat-message strong {
color: var(--player-green);
}
.system-message {
color: #888;
font-style: italic;
}
#chat-input {
height: 52px;
background: #1a1a1a;
border: none;
border-top: 1px solid #444;
color: var(--text-beige);
font-size: 1.15rem;
padding: 0 1.6rem;
outline: none;
font-family: Arial, Helvetica, sans-serif;
}
#chat-input:focus {
background: #222;
}
.waiting-message {
color: #aaa;
font-style: italic;
}
.game-over-message {
font-size: 1.5rem;
color: gold;
text-align: center;
}
.results-display {
text-align: center;
}
.round-winner {
color: var(--player-green);
}
</style>
</head>
<body>
<div id="layout">
<div id="header">
YARN 2026 - Spin your story! <span id="room-display"></span>
</div>
<div id="main-text">
<em>Waiting for the game to begin...</em>
</div>
<div id="game-bar">
<!-- Dynamic content will be inserted here -->
</div>
<div id="chat-area">
<div id="players">
<div id="players-header">Players</div>
<div id="players-list">
<!-- Players will be listed here -->
</div>
</div>
<div id="chat-and-input">
<div id="chat-messages">
<!-- Chat messages will appear here -->
</div>
<input type="text" id="chat-input" placeholder="Type message… (Enter to send)" autocomplete="off">
</div>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
(function() {
// Get room name from URL
const pathParts = window.location.pathname.split('/').filter(p => p);
const roomName = pathParts[0] || 'default';
document.getElementById('room-display').textContent = ` — Room: ${roomName}`;
// Get or create player UUID
let playerUUID = getCookie('yarn_player_uuid');
if (!playerUUID) {
playerUUID = generateUUID();
setCookie('yarn_player_uuid', playerUUID, 365);
}
// Get stored player name
let playerName = getCookie('yarn_player_name') || '';
// Game state
let gameState = null;
let hasVoted = false;
let hasSubmitted = false;
// Connect to Socket.IO
const socket = io();
// Join the room
socket.emit('joinRoom', {
roomName: roomName,
playerUUID: playerUUID,
playerName: playerName
});
// === Socket Event Handlers ===
socket.on('gameState', (state) => {
gameState = state;
updateUI();
});
socket.on('chatHistory', (chat) => {
const chatMessages = document.getElementById('chat-messages');
chatMessages.innerHTML = '';
chat.forEach(msg => appendChatMessage(msg));
});
socket.on('playersUpdate', (players) => {
if (gameState) {
gameState.players = players;
updatePlayersList();
}
});
socket.on('settingsUpdate', (settings) => {
if (gameState) {
gameState.gameSettings = settings;
}
});
socket.on('nameSet', (data) => {
if (data.success) {
playerName = data.name;
setCookie('yarn_player_name', playerName, 365);
document.getElementById('chat-input').disabled = false;
}
});
socket.on('playerJoined', (data) => {
appendSystemMessage(`${data.name} joined the game`);
});
socket.on('playerLeft', (data) => {
appendSystemMessage(`${data.name} left the game`);
});
socket.on('gameStarted', (state) => {
gameState = state;
hasSubmitted = false;
hasVoted = false;
updateUI();
appendSystemMessage('The game has started!');
});
socket.on('timerUpdate', (time) => {
if (gameState) {
gameState.timeRemaining = time;
updateTimer();
}
});
socket.on('votingPhase', (data) => {
if (gameState) {
gameState.currentPhase = 'voting';
gameState.round_data = data.entries;
gameState.timeRemaining = data.timeRemaining;
hasVoted = false;
updateUI();
}
});
socket.on('roundResults', (data) => {
if (gameState) {
gameState.players = data.players;
gameState.yarnStory = data.story;
showRoundResults(data.winner);
}
});
socket.on('newRound', (state) => {
gameState = state;
hasSubmitted = false;
hasVoted = false;
updateUI();
appendSystemMessage('New round started!');
});
socket.on('gameOver', (data) => {
if (gameState) {
gameState.currentPhase = 'gameOver';
gameState.players = data.players;
gameState.yarnStory = data.story;
updateUI();
showGameOver(data.winner);
}
});
socket.on('gameReset', (state) => {
gameState = state;
hasSubmitted = false;
hasVoted = false;
updateUI();
appendSystemMessage('Game has been reset. Waiting for host to start...');
});
socket.on('newChatMessage', (msg) => {
appendChatMessage(msg);
});
socket.on('error', (data) => {
alert(data.message);
});
// === UI Functions ===
function updateUI() {
updateStory();
updateGameBar();
updatePlayersList();
}
function updateStory() {
const mainText = document.getElementById('main-text');
if (!gameState || !gameState.inProgress && gameState.yarnStory.length === 0) {
mainText.innerHTML = '<em>Waiting for the game to begin...</em>';
return;
}
mainText.innerHTML = gameState.yarnStory.map(segment =>
`<span class="story-segment" data-player="${escapeHtml(segment.player)}">${escapeHtml(segment.str)} </span>`
).join('');
}
function updateGameBar() {
const gameBar = document.getElementById('game-bar');
// If player hasn't set name yet
if (!playerName) {
gameBar.innerHTML = `
<span>Enter your name to join:</span>
<input type="text" id="name-input" placeholder="Your name..." maxlength="20">
<button id="set-name-btn">Join Game</button>
`;
document.getElementById('set-name-btn').onclick = setPlayerName;
document.getElementById('name-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') setPlayerName();
});
return;
}
if (!gameState) return;
const isHost = gameState.gameHost === playerUUID;
switch (gameState.currentPhase) {
case 'lobby':
if (isHost) {
gameBar.innerHTML = `
<div class="setting-group">
<label>Score Limit:</label>
<input type="number" id="score-limit" value="${gameState.gameSettings.scoreLimit}" min="10" max="100">
</div>
<div class="setting-group">
<label>Time per Round (sec):</label>
<input type="number" id="time-limit" value="${gameState.gameSettings.timeLimit}" min="15" max="180">
</div>
<button id="start-game-btn">Start Game</button>
`;
document.getElementById('start-game-btn').onclick = startGame;
} else {
gameBar.innerHTML = `
<span class="waiting-message">Waiting for host to start the game...</span>
<span>Score Limit: ${gameState.gameSettings.scoreLimit}</span>
<span>Time Limit: ${gameState.gameSettings.timeLimit}s</span>
`;
}
break;
case 'writing':
if (hasSubmitted) {
gameBar.innerHTML = `
<span id="timer">${gameState.timeRemaining}</span>
<span class="waiting-message">Entry submitted! Waiting for other players...</span>
`;
} else {
gameBar.innerHTML = `
<span id="timer">${gameState.timeRemaining}</span>
<input type="text" id="entry-input" placeholder="Continue the story..." maxlength="200">
<button id="submit-entry-btn">Submit</button>
`;
document.getElementById('submit-entry-btn').onclick = submitEntry;
document.getElementById('entry-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') submitEntry();
});
document.getElementById('entry-input').focus();
}
updateTimer();
break;
case 'voting':
if (hasVoted) {
gameBar.innerHTML = `
<span id="timer">${gameState.timeRemaining}</span>
<span class="waiting-message">Vote cast! Waiting for other players...</span>
`;
} else {
const entries = gameState.round_data.filter(e => e.entry_text);
if (entries.length === 0) {
gameBar.innerHTML = `
<span class="waiting-message">No entries to vote on...</span>
`;
} else {
gameBar.innerHTML = `
<span id="timer">${gameState.timeRemaining}</span>
<div id="voting-container">
${entries.map(e => `
<div class="vote-entry">
<span class="vote-entry-text">"${escapeHtml(e.entry_text)}"</span>
<button class="vote-btn" data-index="${e.index}">Vote</button>
</div>
`).join('')}
</div>
`;
document.querySelectorAll('.vote-btn').forEach(btn => {
btn.onclick = () => submitVote(parseInt(btn.dataset.index));
});
}
}
updateTimer();
break;
case 'gameOver':
if (isHost) {
gameBar.innerHTML = `
<span class="game-over-message">Game Over!</span>
<button id="play-again-btn">Play Again</button>
`;
document.getElementById('play-again-btn').onclick = () => {
socket.emit('playAgain');
};
} else {
gameBar.innerHTML = `
<span class="game-over-message">Game Over! Waiting for host...</span>
`;
}
break;
}
}
function updateTimer() {
const timer = document.getElementById('timer');
if (timer && gameState) {
timer.textContent = gameState.timeRemaining;
if (gameState.timeRemaining <= 10) {
timer.classList.add('warning');
} else {
timer.classList.remove('warning');
}
}
}
function updatePlayersList() {
const playersList = document.getElementById('players-list');
if (!gameState) {
playersList.innerHTML = '';
return;
}
playersList.innerHTML = gameState.players.map(p => {
let statusClass = '';
if (gameState.currentPhase === 'writing') {
statusClass = p.hasSubmitted ? 'submitted' : 'pending';
} else if (gameState.currentPhase === 'voting') {
statusClass = p.hasVoted ? 'submitted' : 'pending';
} else {
statusClass = 'submitted';
}
const hostClass = p.player_id === gameState.gameHost ? 'host' : '';
return `
<div class="player-row ${statusClass} ${hostClass}">
<span class="player-name">${escapeHtml(p.name)}${p.player_id === playerUUID ? ' (You)' : ''}</span>
<span class="player-score">${p.score}</span>
</div>
`;
}).join('');
}
function showRoundResults(winner) {
const gameBar = document.getElementById('game-bar');
if (winner && winner.entry_text) {
const winnerPlayer = gameState.players.find(p => p.player_id === winner.player_id);
const winnerName = winnerPlayer ? winnerPlayer.name : 'Unknown';
gameBar.innerHTML = `
<div class="results-display">
<span class="round-winner">🏆 "${escapeHtml(winner.entry_text)}" by ${escapeHtml(winnerName)} wins!</span>
<br><span class="waiting-message">Next round starting soon...</span>
</div>
`;
} else {
gameBar.innerHTML = `
<div class="results-display">
<span class="waiting-message">No winner this round. Next round starting soon...</span>
</div>
`;
}
updateStory();
updatePlayersList();
}
function showGameOver(winner) {
const mainText = document.getElementById('main-text');
const storyHtml = gameState.yarnStory.map(segment =>
`<span class="story-segment" data-player="${escapeHtml(segment.player)}">${escapeHtml(segment.str)} </span>`
).join('');
mainText.innerHTML = `
<div style="text-align: center; margin-bottom: 1rem;">
<span class="game-over-message">🎉 ${escapeHtml(winner.name)} is victorious with ${winner.score} points! 🎉</span>
</div>
<hr style="border-color: var(--accent-purple); margin: 1rem 0;">
<strong>The Final Story:</strong><br><br>
${storyHtml}
`;
appendSystemMessage(`${winner.name} wins the game with ${winner.score} points!`);
}
// === Action Functions ===
function setPlayerName() {
const input = document.getElementById('name-input');
const name = input.value.trim();
if (name.length < 1 || name.length > 20) {
alert('Please enter a name (1-20 characters)');
return;
}
socket.emit('setName', { name });
}
function startGame() {
const scoreLimit = parseInt(document.getElementById('score-limit').value) || 25;
const timeLimit = parseInt(document.getElementById('time-limit').value) || 60;
socket.emit('startGame', {
scoreLimit: Math.max(10, Math.min(100, scoreLimit)),
timeLimit: Math.max(15, Math.min(180, timeLimit))
});
}
function submitEntry() {
const input = document.getElementById('entry-input');
const entry = input.value.trim();
socket.emit('submitEntry', { entry });
hasSubmitted = true;
updateGameBar();
}
function submitVote(entryIndex) {
socket.emit('submitVote', { entryIndex });
hasVoted = true;
updateGameBar();
}
// === Chat Functions ===
document.getElementById('chat-input').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
const msg = this.value.trim();
if (msg && playerName) {
socket.emit('chatMessage', { msg });
this.value = '';
}
}
});
function appendChatMessage(msg) {
const chatMessages = document.getElementById('chat-messages');
const div = document.createElement('div');
div.className = 'chat-message';
div.innerHTML = `<strong>${escapeHtml(msg.name)}></strong> ${escapeHtml(msg.msg)}`;
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
function appendSystemMessage(msg) {
const chatMessages = document.getElementById('chat-messages');
const div = document.createElement('div');
div.className = 'chat-message system-message';
div.textContent = `* ${msg}`;
chatMessages.appendChild(div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
// === Utility Functions ===
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
function setCookie(name, value, days) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
}
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return decodeURIComponent(parts.pop().split(';').shift());
}
return null;
}
})();
</script>
</body>
</html>

488
server.js Normal file
View File

@ -0,0 +1,488 @@
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const path = require('path');
const cookieParser = require('cookie-parser');
//const { v4: uuidv4 } = require('uuid');
const { randomUUID } = require('node:crypto');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
// Store all game rooms
const games = {};
// YarnGame class
class YarnGame {
constructor(roomName) {
this.roomName = roomName;
this.inProgress = false;
this.gameHost = null;
this.gameSettings = {
scoreLimit: 25,
timeLimit: 60
};
this.players = []; // { player_id, name, score, socketId }
this.yarnStory = []; // { player: "", str: "" }
this.chat = []; // { id, name, msg }
this.round_data = []; // { entry_text, player_id, votes: [] }
this.currentPhase = 'lobby'; // lobby, writing, voting, gameOver
this.timer = null;
this.timeRemaining = 0;
this.submittedPlayers = new Set();
this.votedPlayers = new Set();
}
addPlayer(playerId, name, socketId) {
const existingPlayer = this.players.find(p => p.player_id === playerId);
if (existingPlayer) {
existingPlayer.socketId = socketId;
existingPlayer.name = name;
return existingPlayer;
}
const player = {
player_id: playerId,
name: name,
score: 0,
socketId: socketId
};
this.players.push(player);
// First player becomes host
if (this.players.length === 1) {
this.gameHost = playerId;
}
return player;
}
removePlayer(playerId) {
const index = this.players.findIndex(p => p.player_id === playerId);
if (index !== -1) {
this.players.splice(index, 1);
// Assign new host if host left
if (this.gameHost === playerId && this.players.length > 0) {
this.gameHost = this.players[0].player_id;
}
}
return this.players.length;
}
getPlayer(playerId) {
return this.players.find(p => p.player_id === playerId);
}
getPlayerBySocket(socketId) {
return this.players.find(p => p.socketId === socketId);
}
updatePlayerName(playerId, name) {
const player = this.getPlayer(playerId);
if (player) {
player.name = name;
}
}
startGame(settings) {
this.gameSettings = { ...this.gameSettings, ...settings };
this.inProgress = true;
this.yarnStory = [{ player: "Narrator", str: "It was a dark and stormy night..." }];
this.players.forEach(p => p.score = 0);
this.startWritingPhase();
}
startWritingPhase() {
this.currentPhase = 'writing';
this.round_data = [];
this.submittedPlayers = new Set();
this.timeRemaining = this.gameSettings.timeLimit;
}
submitEntry(playerId, entryText) {
if (this.currentPhase !== 'writing') return false;
if (this.submittedPlayers.has(playerId)) return false;
this.round_data.push({
entry_text: entryText.trim(),
player_id: playerId,
votes: []
});
this.submittedPlayers.add(playerId);
return true;
}
allPlayersSubmitted() {
return this.submittedPlayers.size >= this.players.length;
}
startVotingPhase() {
this.currentPhase = 'voting';
this.votedPlayers = new Set();
this.timeRemaining = 30; // 30 seconds for voting
}
submitVote(playerId, entryIndex) {
if (this.currentPhase !== 'voting') return false;
if (this.votedPlayers.has(playerId)) return false;
const entry = this.round_data[entryIndex];
// Can't vote for own entry
//if (!entry || entry.player_id === playerId) return false;
entry.votes.push(playerId);
this.votedPlayers.add(playerId);
return true;
}
allPlayersVoted() {
return this.votedPlayers.size >= this.players.length;
}
tallyVotes() {
if (this.round_data.length === 0) return null;
// Find winner (most votes)
let winner = this.round_data[0];
for (const entry of this.round_data) {
if (entry.votes.length > winner.votes.length) {
winner = entry;
}
}
// Award points
const winnerPlayer = this.getPlayer(winner.player_id);
if (winnerPlayer) {
winnerPlayer.score += 5 + winner.votes.length;
}
// Add to story
if (winner.entry_text) {
this.yarnStory.push({
player: winnerPlayer ? winnerPlayer.name : "Unknown",
str: winner.entry_text
});
}
return winner;
}
checkGameOver() {
const winner = this.players.find(p => p.score >= this.gameSettings.scoreLimit);
if (winner) {
this.currentPhase = 'gameOver';
this.inProgress = false;
return winner;
}
return null;
}
resetGame() {
this.inProgress = false;
this.currentPhase = 'lobby';
this.round_data = [];
this.yarnStory = [];
this.submittedPlayers = new Set();
this.votedPlayers = new Set();
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
addChatMessage(playerId, msg) {
const player = this.getPlayer(playerId);
const chatMsg = {
id: playerId,
name: player ? player.name : 'Unknown',
msg: msg
};
this.chat.push(chatMsg);
// Keep last 100 messages
if (this.chat.length > 100) {
this.chat.shift();
}
return chatMsg;
}
getState() {
return {
roomName: this.roomName,
inProgress: this.inProgress,
gameHost: this.gameHost,
gameSettings: this.gameSettings,
players: this.players.map(p => ({
player_id: p.player_id,
name: p.name,
score: p.score,
hasSubmitted: this.submittedPlayers.has(p.player_id),
hasVoted: this.votedPlayers.has(p.player_id)
})),
yarnStory: this.yarnStory,
currentPhase: this.currentPhase,
timeRemaining: this.timeRemaining,
round_data: this.currentPhase === 'voting' ? this.round_data.map((e, i) => ({
index: i,
entry_text: e.entry_text,
voteCount: e.votes.length
})) : []
};
}
}
// Get or create a game room
function getOrCreateGame(roomName) {
if (!games[roomName]) {
games[roomName] = new YarnGame(roomName);
}
return games[roomName];
}
// Route for game rooms
app.get('/:roomName', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Socket.IO connection handling
io.on('connection', (socket) => {
let currentRoom = null;
let playerId = null;
socket.on('joinRoom', (data) => {
const { roomName, playerUUID, playerName } = data;
currentRoom = roomName;
playerId = playerUUID;
socket.join(roomName);
const game = getOrCreateGame(roomName);
if (playerName) {
game.addPlayer(playerId, playerName, socket.id);
}
// Send current game state to the joining player
socket.emit('gameState', game.getState());
socket.emit('chatHistory', game.chat);
// Notify others
if (playerName) {
io.to(roomName).emit('playerJoined', {
player_id: playerId,
name: playerName
});
io.to(roomName).emit('playersUpdate', game.getState().players);
}
});
socket.on('setName', (data) => {
const { name } = data;
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game) return;
game.addPlayer(playerId, name, socket.id);
socket.emit('nameSet', { success: true, name });
io.to(currentRoom).emit('playersUpdate', game.getState().players);
io.to(currentRoom).emit('gameState', game.getState());
});
socket.on('updateSettings', (data) => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game || game.gameHost !== playerId) return;
game.gameSettings = { ...game.gameSettings, ...data };
io.to(currentRoom).emit('settingsUpdate', game.gameSettings);
});
socket.on('startGame', (settings) => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game || game.gameHost !== playerId) return;
if (game.players.length < 2) {
socket.emit('error', { message: 'Need at least 2 players to start' });
return;
}
game.startGame(settings);
io.to(currentRoom).emit('gameStarted', game.getState());
startTimer(game);
});
socket.on('submitEntry', (data) => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game) return;
const success = game.submitEntry(playerId, data.entry);
if (success) {
io.to(currentRoom).emit('playersUpdate', game.getState().players);
if (game.allPlayersSubmitted()) {
clearInterval(game.timer);
game.startVotingPhase();
io.to(currentRoom).emit('votingPhase', {
entries: game.round_data.map((e, i) => ({
index: i,
entry_text: e.entry_text
})),
timeRemaining: game.timeRemaining
});
startVotingTimer(game);
}
}
});
socket.on('submitVote', (data) => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game) return;
const success = game.submitVote(playerId, data.entryIndex);
if (success) {
io.to(currentRoom).emit('playersUpdate', game.getState().players);
if (game.allPlayersVoted()) {
clearInterval(game.timer);
endVotingPhase(game);
}
}
});
socket.on('chatMessage', (data) => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game) return;
const chatMsg = game.addChatMessage(playerId, data.msg);
io.to(currentRoom).emit('newChatMessage', chatMsg);
});
socket.on('playAgain', (settings) => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game || game.gameHost !== playerId) return;
game.resetGame();
io.to(currentRoom).emit('gameReset', game.getState());
});
socket.on('disconnect', () => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game) return;
const player = game.getPlayer(playerId);
const playerName = player ? player.name : 'Unknown';
const remaining = game.removePlayer(playerId);
io.to(currentRoom).emit('playerLeft', { player_id: playerId, name: playerName });
io.to(currentRoom).emit('playersUpdate', game.getState().players);
io.to(currentRoom).emit('gameState', game.getState());
// Clean up empty rooms
if (remaining === 0) {
if (game.timer) clearInterval(game.timer);
delete games[currentRoom];
}
});
});
function startTimer(game) {
if (game.timer) clearInterval(game.timer);
game.timer = setInterval(() => {
game.timeRemaining--;
io.to(game.roomName).emit('timerUpdate', game.timeRemaining);
if (game.timeRemaining <= 0) {
clearInterval(game.timer);
// Force submit empty entries for players who haven't submitted
for (const player of game.players) {
if (!game.submittedPlayers.has(player.player_id)) {
game.submitEntry(player.player_id, "");
}
}
game.startVotingPhase();
io.to(game.roomName).emit('votingPhase', {
entries: game.round_data.map((e, i) => ({
index: i,
entry_text: e.entry_text
})),
timeRemaining: game.timeRemaining
});
startVotingTimer(game);
}
}, 1000);
}
function startVotingTimer(game) {
if (game.timer) clearInterval(game.timer);
game.timer = setInterval(() => {
game.timeRemaining--;
io.to(game.roomName).emit('timerUpdate', game.timeRemaining);
if (game.timeRemaining <= 0) {
clearInterval(game.timer);
endVotingPhase(game);
}
}, 1000);
}
function endVotingPhase(game) {
const winner = game.tallyVotes();
io.to(game.roomName).emit('roundResults', {
winner: winner,
players: game.getState().players,
story: game.yarnStory
});
// Check for game over
const gameWinner = game.checkGameOver();
if (gameWinner) {
io.to(game.roomName).emit('gameOver', {
winner: gameWinner,
players: game.getState().players,
story: game.yarnStory
});
} else {
// Start next round after a short delay
setTimeout(() => {
game.startWritingPhase();
io.to(game.roomName).emit('newRound', game.getState());
startTimer(game);
}, 5000);
}
}
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`YARN server running on http://localhost:${PORT}`);
console.log(`Join a room at http://localhost:${PORT}/game101`);
});