Commit: 40e5067
Parent: bb3d72d

Improve color displays

Mårten Åsberg committed on 2025-12-24 at 14:39
.editorconfig +1 -0
diff --git a/.editorconfig b/.editorconfig
index e01631e..f297aed 100644
@@ -1,4 +1,5 @@
insert_final_newline = true
indent_style = space
# JS, TS, Svelte files
[*.{js,ts,svelte,html,css}]
README.md +5 -0
diff --git a/README.md b/README.md
index 3297bdc..400df50 100644
@@ -45,6 +45,7 @@ A real-time multiplayer reaction game that runs across multiple devices using We
## Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd stop-it
@@ -64,6 +65,7 @@ npm run dev
```
This will start:
- **SvelteKit dev server** on `http://localhost:5173`
- **Signaling server** on `http://localhost:3001`
@@ -145,6 +147,7 @@ stop-it/
## Browser Compatibility
Works best on modern browsers with WebRTC support:
- Chrome/Edge (Desktop & Mobile)
- Firefox (Desktop & Mobile)
- Safari (Desktop & Mobile - iOS 11+)
@@ -156,6 +159,7 @@ Works best on modern browsers with WebRTC support:
### Production Build
1. Build the SvelteKit app:
```bash
npm run build
```
@@ -171,6 +175,7 @@ PORT=3001 node server.js
```
Update the socket.io connection URLs in:
- `src/routes/master/+page.svelte`
- `src/routes/join/+page.svelte`
server.js +114 -114
diff --git a/server.js b/server.js
index d013b17..05b681b 100644
@@ -1,13 +1,13 @@
import { createServer } from 'http';
import { Server } from 'socket.io';
import { nanoid } from 'nanoid';
import { createServer } from "http";
import { Server } from "socket.io";
import { nanoid } from "nanoid";
const httpServer = createServer();
const io = new Server(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST']
}
cors: {
origin: "*",
methods: ["GET", "POST"],
},
});
// Room state management
@@ -17,117 +17,117 @@ const socketToRoom = new Map();
// Generate a unique 6-character room code
function generateRoomCode() {
let code;
do {
code = nanoid(6).toUpperCase();
} while (rooms.has(code));
return code;
let code;
do {
code = nanoid(6).toUpperCase();
} while (rooms.has(code));
return code;
}
io.on('connection', (socket) => {
console.log('Client connected:', socket.id);
// Master creates a room
socket.on('create-room', (callback) => {
const roomCode = generateRoomCode();
rooms.set(roomCode, {
master: socket.id,
displays: [],
createdAt: Date.now()
});
socketToRoom.set(socket.id, roomCode);
socket.join(roomCode);
console.log(`Room created: ${roomCode} by ${socket.id}`);
callback({ success: true, roomCode });
});
// Display device joins a room
socket.on('join-room', (roomCode, callback) => {
const room = rooms.get(roomCode);
if (!room) {
callback({ success: false, error: 'Room not found' });
return;
}
// Add display to room
room.displays.push(socket.id);
socketToRoom.set(socket.id, roomCode);
socket.join(roomCode);
// Notify master of new display
io.to(room.master).emit('display-joined', {
displayId: socket.id,
totalDisplays: room.displays.length
});
console.log(`Display ${socket.id} joined room ${roomCode}`);
callback({ success: true, masterId: room.master });
});
// Game commands: master sends to specific display
socket.on('game-command', ({ to, command }) => {
io.to(to).emit('game-command', command);
});
// Game broadcast: master sends to all displays in room
socket.on('game-broadcast', ({ roomCode, command }) => {
const room = rooms.get(roomCode);
if (room) {
room.displays.forEach(displayId => {
io.to(displayId).emit('game-command', command);
});
}
});
// Display actions: display sends to master
socket.on('display-action', (action) => {
const roomCode = socketToRoom.get(socket.id);
if (roomCode) {
const room = rooms.get(roomCode);
if (room) {
io.to(room.master).emit('display-action', {
from: socket.id,
action
});
}
}
});
// Handle disconnections
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
const roomCode = socketToRoom.get(socket.id);
if (roomCode) {
const room = rooms.get(roomCode);
if (room) {
// If master disconnects, close the room
if (room.master === socket.id) {
console.log(`Master disconnected, closing room ${roomCode}`);
// Notify all displays
room.displays.forEach(displayId => {
io.to(displayId).emit('room-closed');
socketToRoom.delete(displayId);
});
rooms.delete(roomCode);
} else {
// Remove display from room
room.displays = room.displays.filter(id => id !== socket.id);
// Notify master
io.to(room.master).emit('display-left', {
displayId: socket.id,
totalDisplays: room.displays.length
});
}
}
socketToRoom.delete(socket.id);
}
});
io.on("connection", (socket) => {
console.log("Client connected:", socket.id);
// Master creates a room
socket.on("create-room", (callback) => {
const roomCode = generateRoomCode();
rooms.set(roomCode, {
master: socket.id,
displays: [],
createdAt: Date.now(),
});
socketToRoom.set(socket.id, roomCode);
socket.join(roomCode);
console.log(`Room created: ${roomCode} by ${socket.id}`);
callback({ success: true, roomCode });
});
// Display device joins a room
socket.on("join-room", (roomCode, callback) => {
const room = rooms.get(roomCode);
if (!room) {
callback({ success: false, error: "Room not found" });
return;
}
// Add display to room
room.displays.push(socket.id);
socketToRoom.set(socket.id, roomCode);
socket.join(roomCode);
// Notify master of new display
io.to(room.master).emit("display-joined", {
displayId: socket.id,
totalDisplays: room.displays.length,
});
console.log(`Display ${socket.id} joined room ${roomCode}`);
callback({ success: true, masterId: room.master });
});
// Game commands: master sends to specific display
socket.on("game-command", ({ to, command }) => {
io.to(to).emit("game-command", command);
});
// Game broadcast: master sends to all displays in room
socket.on("game-broadcast", ({ roomCode, command }) => {
const room = rooms.get(roomCode);
if (room) {
room.displays.forEach((displayId) => {
io.to(displayId).emit("game-command", command);
});
}
});
// Display actions: display sends to master
socket.on("display-action", (action) => {
const roomCode = socketToRoom.get(socket.id);
if (roomCode) {
const room = rooms.get(roomCode);
if (room) {
io.to(room.master).emit("display-action", {
from: socket.id,
action,
});
}
}
});
// Handle disconnections
socket.on("disconnect", () => {
console.log("Client disconnected:", socket.id);
const roomCode = socketToRoom.get(socket.id);
if (roomCode) {
const room = rooms.get(roomCode);
if (room) {
// If master disconnects, close the room
if (room.master === socket.id) {
console.log(`Master disconnected, closing room ${roomCode}`);
// Notify all displays
room.displays.forEach((displayId) => {
io.to(displayId).emit("room-closed");
socketToRoom.delete(displayId);
});
rooms.delete(roomCode);
} else {
// Remove display from room
room.displays = room.displays.filter((id) => id !== socket.id);
// Notify master
io.to(room.master).emit("display-left", {
displayId: socket.id,
totalDisplays: room.displays.length,
});
}
}
socketToRoom.delete(socket.id);
}
});
});
const PORT = process.env.PORT || 3001;
httpServer.listen(PORT, () => {
console.log(`Signaling server running on port ${PORT}`);
console.log(`Signaling server running on port ${PORT}`);
});
server/index.js +118 -118
diff --git a/server/index.js b/server/index.js
index 31c4d17..13e1db1 100644
@@ -1,8 +1,8 @@
import { handler } from '../build/handler.js';
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import { nanoid } from 'nanoid';
import { handler } from "../build/handler.js";
import express from "express";
import { createServer } from "http";
import { Server } from "socket.io";
import { nanoid } from "nanoid";
// Create Express app and HTTP server
const app = express();
@@ -10,10 +10,10 @@ const httpServer = createServer(app);
// Initialize Socket.IO
const io = new Server(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST']
}
cors: {
origin: "*",
methods: ["GET", "POST"],
},
});
// Room state management
@@ -23,115 +23,115 @@ const socketToRoom = new Map();
// Generate a unique 6-character room code
function generateRoomCode() {
let code;
do {
code = nanoid(6).toUpperCase();
} while (rooms.has(code));
return code;
let code;
do {
code = nanoid(6).toUpperCase();
} while (rooms.has(code));
return code;
}
// Socket.IO signaling logic
io.on('connection', (socket) => {
console.log('Client connected:', socket.id);
// Master creates a room
socket.on('create-room', (callback) => {
const roomCode = generateRoomCode();
rooms.set(roomCode, {
master: socket.id,
displays: [],
createdAt: Date.now()
});
socketToRoom.set(socket.id, roomCode);
socket.join(roomCode);
console.log(`Room created: ${roomCode} by ${socket.id}`);
callback({ success: true, roomCode });
});
// Display device joins a room
socket.on('join-room', (roomCode, callback) => {
const room = rooms.get(roomCode);
if (!room) {
callback({ success: false, error: 'Room not found' });
return;
}
// Add display to room
room.displays.push(socket.id);
socketToRoom.set(socket.id, roomCode);
socket.join(roomCode);
// Notify master of new display
io.to(room.master).emit('display-joined', {
displayId: socket.id,
totalDisplays: room.displays.length
});
console.log(`Display ${socket.id} joined room ${roomCode}`);
callback({ success: true, masterId: room.master });
});
// Game commands: master sends to specific display
socket.on('game-command', ({ to, command }) => {
io.to(to).emit('game-command', command);
});
// Game broadcast: master sends to all displays in room
socket.on('game-broadcast', ({ roomCode, command }) => {
const room = rooms.get(roomCode);
if (room) {
room.displays.forEach(displayId => {
io.to(displayId).emit('game-command', command);
});
}
});
// Display actions: display sends to master
socket.on('display-action', (action) => {
const roomCode = socketToRoom.get(socket.id);
if (roomCode) {
const room = rooms.get(roomCode);
if (room) {
io.to(room.master).emit('display-action', {
from: socket.id,
action
});
}
}
});
// Handle disconnections
socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
const roomCode = socketToRoom.get(socket.id);
if (roomCode) {
const room = rooms.get(roomCode);
if (room) {
// If master disconnects, close the room
if (room.master === socket.id) {
console.log(`Master disconnected, closing room ${roomCode}`);
// Notify all displays
room.displays.forEach(displayId => {
io.to(displayId).emit('room-closed');
socketToRoom.delete(displayId);
});
rooms.delete(roomCode);
} else {
// Remove display from room
room.displays = room.displays.filter(id => id !== socket.id);
// Notify master
io.to(room.master).emit('display-left', {
displayId: socket.id,
totalDisplays: room.displays.length
});
}
}
socketToRoom.delete(socket.id);
}
});
io.on("connection", (socket) => {
console.log("Client connected:", socket.id);
// Master creates a room
socket.on("create-room", (callback) => {
const roomCode = generateRoomCode();
rooms.set(roomCode, {
master: socket.id,
displays: [],
createdAt: Date.now(),
});
socketToRoom.set(socket.id, roomCode);
socket.join(roomCode);
console.log(`Room created: ${roomCode} by ${socket.id}`);
callback({ success: true, roomCode });
});
// Display device joins a room
socket.on("join-room", (roomCode, callback) => {
const room = rooms.get(roomCode);
if (!room) {
callback({ success: false, error: "Room not found" });
return;
}
// Add display to room
room.displays.push(socket.id);
socketToRoom.set(socket.id, roomCode);
socket.join(roomCode);
// Notify master of new display
io.to(room.master).emit("display-joined", {
displayId: socket.id,
totalDisplays: room.displays.length,
});
console.log(`Display ${socket.id} joined room ${roomCode}`);
callback({ success: true, masterId: room.master });
});
// Game commands: master sends to specific display
socket.on("game-command", ({ to, command }) => {
io.to(to).emit("game-command", command);
});
// Game broadcast: master sends to all displays in room
socket.on("game-broadcast", ({ roomCode, command }) => {
const room = rooms.get(roomCode);
if (room) {
room.displays.forEach((displayId) => {
io.to(displayId).emit("game-command", command);
});
}
});
// Display actions: display sends to master
socket.on("display-action", (action) => {
const roomCode = socketToRoom.get(socket.id);
if (roomCode) {
const room = rooms.get(roomCode);
if (room) {
io.to(room.master).emit("display-action", {
from: socket.id,
action,
});
}
}
});
// Handle disconnections
socket.on("disconnect", () => {
console.log("Client disconnected:", socket.id);
const roomCode = socketToRoom.get(socket.id);
if (roomCode) {
const room = rooms.get(roomCode);
if (room) {
// If master disconnects, close the room
if (room.master === socket.id) {
console.log(`Master disconnected, closing room ${roomCode}`);
// Notify all displays
room.displays.forEach((displayId) => {
io.to(displayId).emit("room-closed");
socketToRoom.delete(displayId);
});
rooms.delete(roomCode);
} else {
// Remove display from room
room.displays = room.displays.filter((id) => id !== socket.id);
// Notify master
io.to(room.master).emit("display-left", {
displayId: socket.id,
totalDisplays: room.displays.length,
});
}
}
socketToRoom.delete(socket.id);
}
});
});
// SvelteKit handler MUST be added last
@@ -139,7 +139,7 @@ app.use(handler);
const PORT = process.env.PORT || 3000;
httpServer.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`- SvelteKit app available`);
console.log(`- Socket.IO signaling available`);
console.log(`Server running on port ${PORT}`);
console.log(`- SvelteKit app available`);
console.log(`- Socket.IO signaling available`);
});
src/app.d.ts +2 -2
diff --git a/src/app.d.ts b/src/app.d.ts
index c13f2d8..6e5c238 100644
@@ -12,13 +12,13 @@ declare global {
// Wake Lock API types
interface WakeLockSentinel extends EventTarget {
readonly released: boolean;
readonly type: 'screen';
readonly type: "screen";
release(): Promise<void>;
}
interface Navigator {
wakeLock?: {
request(type: 'screen'): Promise<WakeLockSentinel>;
request(type: "screen"): Promise<WakeLockSentinel>;
};
}
}
src/lib/components/GameScreen.svelte +97 -57
diff --git a/src/lib/components/GameScreen.svelte b/src/lib/components/GameScreen.svelte
index 323da47..5151eaa 100644
@@ -1,71 +1,111 @@
<script lang="ts">
interface Props {
color: string;
isMaster: boolean;
onTap?: () => void;
}
import { PLAYER_COLORS } from "$lib/types";
import { onMount } from "svelte";
let { color = '', isMaster = false, onTap }: Props = $props();
interface Props {
playerCount: number;
onTap: (playerId: number) => void;
}
function handleClick() {
if (color && onTap) {
onTap();
}
}
let { playerCount, onTap }: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
handleClick();
}
}
let currentPlayer: number | null = $state(null);
let timeout: NodeJS.Timeout | null = null;
onMount(() => {
nextTimeout();
return () => {
if (timeout) {
clearTimeout(timeout);
}
};
});
function nextTimeout() {
const minInterval = 800;
const maxInterval = 6000;
const interval = Math.random() * (maxInterval - minInterval) + minInterval;
timeout = setTimeout(() => {
// Choose a random player
currentPlayer = Math.floor(Math.random() * playerCount);
// Clear current player after a short delay
const minInterval = 800;
const maxInterval = 2500;
const interval = Math.random() * (maxInterval - minInterval) + minInterval;
timeout = setTimeout(() => {
currentPlayer = null;
nextTimeout();
}, interval);
}, interval);
}
function handleClick() {
if (currentPlayer != null) {
onTap(currentPlayer);
if (timeout) {
clearTimeout(timeout);
}
currentPlayer = null;
nextTimeout();
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter" || event.key === " ") {
handleClick();
}
}
</script>
<div
class="game-screen"
style="background-color: {color || '#000000'}"
onclick={handleClick}
onkeydown={handleKeydown}
role="button"
tabindex="0"
class="game-screen"
style="background-color: {currentPlayer != null ? PLAYER_COLORS[currentPlayer] : '#000000'}"
onclick={handleClick}
onkeydown={handleKeydown}
role="button"
tabindex="0"
>
{#if color}
<div class="tap-indicator">TAP!</div>
{/if}
{#if currentPlayer != null}
<div class="tap-indicator">TAP!</div>
{/if}
</div>
<style>
.game-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.game-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.tap-indicator {
font-size: 4rem;
font-weight: bold;
color: rgba(255, 255, 255, 0.9);
text-shadow:
0 0 10px rgba(0, 0, 0, 0.5),
0 0 20px rgba(0, 0, 0, 0.3);
animation: pulse 0.5s ease-in-out infinite alternate;
}
.tap-indicator {
font-size: 4rem;
font-weight: bold;
color: rgba(255, 255, 255, 0.9);
text-shadow:
0 0 10px rgba(0, 0, 0, 0.5),
0 0 20px rgba(0, 0, 0, 0.3);
animation: pulse 0.5s ease-in-out infinite alternate;
}
@keyframes pulse {
from {
transform: scale(1);
opacity: 0.8;
}
to {
transform: scale(1.1);
opacity: 1;
}
}
@keyframes pulse {
from {
transform: scale(1);
opacity: 0.8;
}
to {
transform: scale(1.1);
opacity: 1;
}
}
</style>
src/lib/components/ResultsScreen.svelte +193 -196
diff --git a/src/lib/components/ResultsScreen.svelte b/src/lib/components/ResultsScreen.svelte
index 788a051..e803667 100644
@@ -1,208 +1,205 @@
<script lang="ts">
import type { Player } from '$lib/types';
import type { Player } from "$lib/types";
interface Props {
scores: Player[];
isMaster: boolean;
onRestart?: () => void;
onQuit?: () => void;
}
interface Props {
scores: Player[];
isMaster: boolean;
onRestart?: () => void;
onQuit?: () => void;
}
let { scores = [], isMaster = false, onRestart, onQuit }: Props = $props();
let { scores = [], isMaster = false, onRestart, onQuit }: Props = $props();
// Sort players by score (descending)
const sortedScores = $derived([...scores].sort((a, b) => b.score - a.score));
// Sort players by score (descending)
const sortedScores = $derived([...scores].sort((a, b) => b.score - a.score));
const winner = $derived(sortedScores[0]);
const winner = $derived(sortedScores[0]);
</script>
<div class="results-screen">
<div class="results-container">
<h1>Game Over!</h1>
{#if winner}
<div class="winner" style="background-color: {winner.color}">
<div class="winner-text">
Winner: Player {winner.id + 1}
</div>
<div class="winner-score">{winner.score} points</div>
</div>
{/if}
<div class="scores">
<h2>Final Scores</h2>
<div class="scores-list">
{#each sortedScores as player, index}
<div class="score-item">
<div class="rank">#{index + 1}</div>
<div class="player-info">
<div
class="player-color-badge"
style="background-color: {player.color}"
></div>
<div class="player-name">Player {player.id + 1}</div>
</div>
<div class="score">{player.score}</div>
</div>
{/each}
</div>
</div>
{#if isMaster}
<div class="controls">
<button class="restart-button" onclick={onRestart}>Play Again</button>
<button class="quit-button" onclick={onQuit}>Quit</button>
</div>
{/if}
</div>
<div class="results-container">
<h1>Game Over!</h1>
{#if winner}
<div class="winner" style="background-color: {winner.color}">
<div class="winner-text">
Winner: Player {winner.id + 1}
</div>
<div class="winner-score">{winner.score} points</div>
</div>
{/if}
<div class="scores">
<h2>Final Scores</h2>
<div class="scores-list">
{#each sortedScores as player, index}
<div class="score-item">
<div class="rank">#{index + 1}</div>
<div class="player-info">
<div class="player-color-badge" style="background-color: {player.color}"></div>
<div class="player-name">Player {player.id + 1}</div>
</div>
<div class="score">{player.score}</div>
</div>
{/each}
</div>
</div>
{#if isMaster}
<div class="controls">
<button class="restart-button" onclick={onRestart}>Play Again</button>
<button class="quit-button" onclick={onQuit}>Quit</button>
</div>
{/if}
</div>
</div>
<style>
.results-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
justify-content: center;
align-items: center;
overflow-y: auto;
}
.results-container {
max-width: 600px;
width: 90%;
background: white;
border-radius: 16px;
padding: 2rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
h1 {
text-align: center;
color: #333;
margin-bottom: 2rem;
font-size: 2.5rem;
}
.winner {
text-align: center;
padding: 2rem;
border-radius: 12px;
margin-bottom: 2rem;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.winner-text {
font-size: 1.5rem;
font-weight: bold;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
margin-bottom: 0.5rem;
}
.winner-score {
font-size: 2rem;
font-weight: bold;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.scores {
margin-bottom: 2rem;
}
.scores h2 {
text-align: center;
color: #555;
margin-bottom: 1rem;
}
.scores-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.score-item {
display: flex;
align-items: center;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
gap: 1rem;
}
.rank {
font-size: 1.25rem;
font-weight: bold;
color: #666;
min-width: 3rem;
}
.player-info {
flex: 1;
display: flex;
align-items: center;
gap: 1rem;
}
.player-color-badge {
width: 40px;
height: 40px;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.player-name {
font-size: 1.1rem;
font-weight: 600;
color: #333;
}
.score {
font-size: 1.5rem;
font-weight: bold;
color: #007bff;
min-width: 4rem;
text-align: right;
}
.controls {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.restart-button,
.quit-button {
flex: 1;
padding: 1rem;
font-size: 1.1rem;
font-weight: bold;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.restart-button {
background: #28a745;
color: white;
}
.restart-button:hover {
background: #218838;
}
.quit-button {
background: #dc3545;
color: white;
}
.quit-button:hover {
background: #c82333;
}
.results-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
justify-content: center;
align-items: center;
overflow-y: auto;
}
.results-container {
max-width: 600px;
width: 90%;
background: white;
border-radius: 16px;
padding: 2rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
h1 {
text-align: center;
color: #333;
margin-bottom: 2rem;
font-size: 2.5rem;
}
.winner {
text-align: center;
padding: 2rem;
border-radius: 12px;
margin-bottom: 2rem;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
}
.winner-text {
font-size: 1.5rem;
font-weight: bold;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
margin-bottom: 0.5rem;
}
.winner-score {
font-size: 2rem;
font-weight: bold;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.scores {
margin-bottom: 2rem;
}
.scores h2 {
text-align: center;
color: #555;
margin-bottom: 1rem;
}
.scores-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.score-item {
display: flex;
align-items: center;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
gap: 1rem;
}
.rank {
font-size: 1.25rem;
font-weight: bold;
color: #666;
min-width: 3rem;
}
.player-info {
flex: 1;
display: flex;
align-items: center;
gap: 1rem;
}
.player-color-badge {
width: 40px;
height: 40px;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.player-name {
font-size: 1.1rem;
font-weight: 600;
color: #333;
}
.score {
font-size: 1.5rem;
font-weight: bold;
color: #007bff;
min-width: 4rem;
text-align: right;
}
.controls {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.restart-button,
.quit-button {
flex: 1;
padding: 1rem;
font-size: 1.1rem;
font-weight: bold;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.restart-button {
background: #28a745;
color: white;
}
.restart-button:hover {
background: #218838;
}
.quit-button {
background: #dc3545;
color: white;
}
.quit-button:hover {
background: #c82333;
}
</style>
src/lib/types.ts +21 -21
diff --git a/src/lib/types.ts b/src/lib/types.ts
index e0622aa..dd48abd 100644
@@ -1,35 +1,35 @@
export interface Player {
id: number;
color: string;
score: number;
id: number;
color: string;
score: number;
}
export interface GameConfig {
numPlayers: number;
duration: number; // in seconds
numPlayers: number;
duration: number; // in seconds
}
export interface GameCommand {
type: 'show-color' | 'hide-color' | 'game-over' | 'show-results';
playerId?: number;
color?: string;
scores?: Player[];
type: "show-color" | "hide-color" | "game-over" | "show-results";
playerId?: number;
color?: string;
scores?: Player[];
}
export interface DisplayDevice {
id: string;
connected: boolean;
id: string;
connected: boolean;
}
export const PLAYER_COLORS = [
'#FF0000', // Red
'#00FF00', // Green
'#0000FF', // Blue
'#FFFF00', // Yellow
'#FF00FF', // Magenta
'#00FFFF', // Cyan
'#FFA500', // Orange
'#800080', // Purple
'#FFC0CB', // Pink
'#A52A2A' // Brown
"#FF0000", // Red
"#00FF00", // Green
"#0000FF", // Blue
"#FFFF00", // Yellow
"#FF00FF", // Magenta
"#00FFFF", // Cyan
"#FFA500", // Orange
"#800080", // Purple
"#FFC0CB", // Pink
"#A52A2A", // Brown
];
src/routes/+layout.svelte +3 -3
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 9497542..efe6120 100644
@@ -1,11 +1,11 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import favicon from "$lib/assets/favicon.svg";
const { children } = $props();
const { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="icon" href={favicon} />
</svelte:head>
{@render children()}
src/routes/+page.svelte +180 -180
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 3af2f55..22ea298 100644
@@ -1,190 +1,190 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto } from "$app/navigation";
function createRoom() {
goto('/master');
}
function createRoom() {
goto("/master");
}
function joinRoom() {
goto('/join');
}
function joinRoom() {
goto("/join");
}
</script>
<div class="home-container">
<div class="content">
<h1>Stop It!</h1>
<p class="subtitle">Multi-Device Reaction Game</p>
<div class="button-container">
<button class="primary-button" onclick={createRoom}>
<div class="button-icon">🎮</div>
<div class="button-text">
<div class="button-title">Create Game</div>
<div class="button-description">Start as master device</div>
</div>
</button>
<button class="secondary-button" onclick={joinRoom}>
<div class="button-icon">📱</div>
<div class="button-text">
<div class="button-title">Join Game</div>
<div class="button-description">Connect as display device</div>
</div>
</button>
</div>
<div class="info">
<h3>How to Play:</h3>
<ol>
<li>One device creates a game as the master</li>
<li>Other devices join using the room code</li>
<li>Configure players and duration on the master</li>
<li>When a color appears on your screen, tap it quickly!</li>
<li>The player with the most points wins</li>
</ol>
</div>
</div>
<div class="content">
<h1>Stop It!</h1>
<p class="subtitle">Multi-Device Reaction Game</p>
<div class="button-container">
<button class="primary-button" onclick={createRoom}>
<div class="button-icon">🎮</div>
<div class="button-text">
<div class="button-title">Create Game</div>
<div class="button-description">Start as master device</div>
</div>
</button>
<button class="secondary-button" onclick={joinRoom}>
<div class="button-icon">📱</div>
<div class="button-text">
<div class="button-title">Join Game</div>
<div class="button-description">Connect as display device</div>
</div>
</button>
</div>
<div class="info">
<h3>How to Play:</h3>
<ol>
<li>One device creates a game as the master</li>
<li>Other devices join using the room code</li>
<li>Configure players and duration on the master</li>
<li>When a color appears on your screen, tap it quickly!</li>
<li>The player with the most points wins</li>
</ol>
</div>
</div>
</div>
<style>
:global(body) {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.home-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.content {
max-width: 600px;
width: 100%;
text-align: center;
}
h1 {
font-size: 4rem;
color: white;
margin-bottom: 0.5rem;
text-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
.subtitle {
font-size: 1.5rem;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 3rem;
}
.button-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-bottom: 3rem;
}
.primary-button,
.secondary-button {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1.5rem 2rem;
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
text-align: left;
}
.primary-button {
background: white;
color: #333;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.primary-button:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
}
.secondary-button {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 2px solid white;
backdrop-filter: blur(10px);
}
.secondary-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-4px);
}
.button-icon {
font-size: 3rem;
}
.button-text {
flex: 1;
}
.button-title {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 0.25rem;
}
.button-description {
font-size: 1rem;
opacity: 0.8;
}
.info {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 2rem;
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.info h3 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.3rem;
}
.info ol {
text-align: left;
margin: 0;
padding-left: 1.5rem;
}
.info li {
margin-bottom: 0.75rem;
font-size: 1rem;
line-height: 1.5;
}
@media (max-width: 600px) {
h1 {
font-size: 3rem;
}
.subtitle {
font-size: 1.2rem;
}
.button-title {
font-size: 1.25rem;
}
.button-icon {
font-size: 2.5rem;
}
}
:global(body) {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.home-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
}
.content {
max-width: 600px;
width: 100%;
text-align: center;
}
h1 {
font-size: 4rem;
color: white;
margin-bottom: 0.5rem;
text-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
.subtitle {
font-size: 1.5rem;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 3rem;
}
.button-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-bottom: 3rem;
}
.primary-button,
.secondary-button {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1.5rem 2rem;
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
text-align: left;
}
.primary-button {
background: white;
color: #333;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.primary-button:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
}
.secondary-button {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 2px solid white;
backdrop-filter: blur(10px);
}
.secondary-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-4px);
}
.button-icon {
font-size: 3rem;
}
.button-text {
flex: 1;
}
.button-title {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 0.25rem;
}
.button-description {
font-size: 1rem;
opacity: 0.8;
}
.info {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 2rem;
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.info h3 {
margin-top: 0;
margin-bottom: 1rem;
font-size: 1.3rem;
}
.info ol {
text-align: left;
margin: 0;
padding-left: 1.5rem;
}
.info li {
margin-bottom: 0.75rem;
font-size: 1rem;
line-height: 1.5;
}
@media (max-width: 600px) {
h1 {
font-size: 3rem;
}
.subtitle {
font-size: 1.2rem;
}
.button-title {
font-size: 1.25rem;
}
.button-icon {
font-size: 2.5rem;
}
}
</style>
src/routes/join/+page.svelte +263 -277
diff --git a/src/routes/join/+page.svelte b/src/routes/join/+page.svelte
index 21ced44..53292c3 100644
@@ -1,284 +1,270 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { io, type Socket } from 'socket.io-client';
import GameScreen from '$lib/components/GameScreen.svelte';
import ResultsScreen from '$lib/components/ResultsScreen.svelte';
import type { Player } from '$lib/types';
let socket: Socket | null = null;
let roomCodeInput = $state('');
// State
let gameState = $state<'join' | 'waiting' | 'playing' | 'results'>('join');
let currentColor = $state('');
let currentPlayerId = $state<number | null>(null);
let scores = $state<Player[]>([]);
let errorMessage = $state('');
onMount(() => {
// Connect to server
// In production, Socket.IO runs on same server as SvelteKit
// In development, it runs on separate port (3001)
const socketUrl = import.meta.env.DEV ? 'http://localhost:3001' : window.location.origin;
socket = io(socketUrl);
socket.on('connect', () => {
console.log('Connected to server');
});
socket.on('game-command', (command: any) => {
handleCommand(command);
});
socket.on('room-closed', () => {
console.log('Room closed by master');
gameState = 'join';
currentColor = '';
releaseWakeLock();
alert('Room closed by master device');
});
});
onDestroy(() => {
socket?.disconnect();
releaseWakeLock();
});
function joinRoom() {
if (!roomCodeInput.trim()) {
errorMessage = 'Please enter a room code';
return;
}
const code = roomCodeInput.toUpperCase().trim();
socket?.emit('join-room', code, (response: any) => {
if (response.success) {
console.log('Joined room successfully');
errorMessage = '';
gameState = 'waiting';
} else {
errorMessage = response.error || 'Failed to join room';
}
});
}
let wakeLock: WakeLockSentinel | null = null;
async function requestWakeLock() {
if ('wakeLock' in navigator) {
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => {
console.log('Wake lock released');
});
} catch (err) {
console.error('Wake lock error:', err);
}
}
}
function releaseWakeLock() {
if (wakeLock) {
wakeLock.release();
wakeLock = null;
}
}
async function handleCommand(command: any) {
console.log('Received command:', command);
switch (command.type) {
case 'start-game':
gameState = 'playing';
currentColor = '';
await requestWakeLock();
break;
case 'show-color':
currentColor = command.color;
currentPlayerId = command.playerId;
break;
case 'hide-color':
currentColor = '';
currentPlayerId = null;
break;
case 'show-results':
gameState = 'results';
scores = command.scores;
releaseWakeLock();
break;
}
}
function handleTap() {
if (currentPlayerId !== null && currentColor) {
// Send tap event to master
socket?.emit('display-action', {
type: 'tap',
playerId: currentPlayerId
});
// Hide color immediately
currentColor = '';
currentPlayerId = null;
}
}
import { onMount, onDestroy } from "svelte";
import { io, type Socket } from "socket.io-client";
import GameScreen from "$lib/components/GameScreen.svelte";
import ResultsScreen from "$lib/components/ResultsScreen.svelte";
import type { Player } from "$lib/types";
let socket: Socket | null = null;
let roomCodeInput = $state("");
// State
let gameState = $state<"join" | "waiting" | "playing" | "results">("join");
let playerCount = $state(0);
let scores = $state<Player[]>([]);
let errorMessage = $state("");
onMount(() => {
// Connect to server
// In production, Socket.IO runs on same server as SvelteKit
// In development, it runs on separate port (3001)
const socketUrl = import.meta.env.DEV ? "http://localhost:3001" : window.location.origin;
socket = io(socketUrl);
socket.on("connect", () => {
console.log("Connected to server");
});
socket.on("game-command", (command: any) => {
handleCommand(command);
});
socket.on("room-closed", () => {
console.log("Room closed by master");
gameState = "join";
releaseWakeLock();
alert("Room closed by master device");
});
});
onDestroy(() => {
socket?.disconnect();
releaseWakeLock();
});
function joinRoom() {
if (!roomCodeInput.trim()) {
errorMessage = "Please enter a room code";
return;
}
const code = roomCodeInput.toUpperCase().trim();
socket?.emit("join-room", code, (response: any) => {
if (response.success) {
console.log("Joined room successfully");
errorMessage = "";
gameState = "waiting";
} else {
errorMessage = response.error || "Failed to join room";
}
});
}
let wakeLock: WakeLockSentinel | null = null;
async function requestWakeLock() {
if ("wakeLock" in navigator) {
try {
wakeLock = await navigator.wakeLock.request("screen");
wakeLock.addEventListener("release", () => {
console.log("Wake lock released");
});
} catch (err) {
console.error("Wake lock error:", err);
}
}
}
function releaseWakeLock() {
if (wakeLock) {
wakeLock.release();
wakeLock = null;
}
}
async function handleCommand(command: any) {
console.log("Received command:", command);
switch (command.type) {
case "start-game":
gameState = "playing";
playerCount = command.players.length;
await requestWakeLock();
break;
case "show-results":
gameState = "results";
scores = command.scores;
releaseWakeLock();
break;
}
}
function handleTap(playerId: number) {
// Send tap event to master
socket?.emit("display-action", {
type: "tap",
playerId,
});
}
</script>
{#if gameState === 'join'}
<div class="join-container">
<h1>Join Game</h1>
<div class="join-form">
<div class="form-group">
<label for="roomCode">Enter Room Code:</label>
<input
type="text"
id="roomCode"
bind:value={roomCodeInput}
placeholder="ABC123"
maxlength="6"
onkeydown={(e) => e.key === 'Enter' && joinRoom()}
/>
</div>
{#if errorMessage}
<div class="error">{errorMessage}</div>
{/if}
<button class="join-button" onclick={joinRoom}>Join Room</button>
</div>
</div>
{:else if gameState === 'waiting'}
<div class="waiting-container">
<h1>Waiting for Game to Start</h1>
<p>Connected to room. Waiting for master to start the game...</p>
<div class="spinner"></div>
</div>
{:else if gameState === 'playing'}
<GameScreen color={currentColor} isMaster={false} onTap={handleTap} />
{:else if gameState === 'results'}
<ResultsScreen {scores} isMaster={false} />
{#if gameState === "join"}
<div class="join-container">
<h1>Join Game</h1>
<div class="join-form">
<div class="form-group">
<label for="roomCode">Enter Room Code:</label>
<input
type="text"
id="roomCode"
bind:value={roomCodeInput}
placeholder="ABC123"
maxlength="6"
onkeydown={(e) => e.key === "Enter" && joinRoom()}
/>
</div>
{#if errorMessage}
<div class="error">{errorMessage}</div>
{/if}
<button class="join-button" onclick={joinRoom}>Join Room</button>
</div>
</div>
{:else if gameState === "waiting"}
<div class="waiting-container">
<h1>Waiting for Game to Start</h1>
<p>Connected to room. Waiting for master to start the game...</p>
<div class="spinner"></div>
</div>
{:else if gameState === "playing"}
<GameScreen {playerCount} onTap={handleTap} />
{:else if gameState === "results"}
<ResultsScreen {scores} isMaster={false} />
{/if}
<style>
:global(body) {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.join-container {
max-width: 400px;
margin: 0 auto;
padding: 2rem;
text-align: center;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
}
h1 {
color: #333;
margin-bottom: 2rem;
}
.join-form {
background: #fff;
border: 2px solid #ddd;
border-radius: 8px;
padding: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
text-align: left;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
color: #555;
}
input[type='text'] {
width: 100%;
padding: 1rem;
font-size: 1.5rem;
text-align: center;
text-transform: uppercase;
border: 2px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
letter-spacing: 0.25rem;
}
.error {
color: #dc3545;
margin-bottom: 1rem;
padding: 0.75rem;
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
}
.join-button {
width: 100%;
padding: 1rem;
font-size: 1.25rem;
font-weight: bold;
background: #007bff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.join-button:hover {
background: #0056b3;
}
.join-button:active {
background: #004085;
}
.waiting-container {
text-align: center;
padding: 4rem 2rem;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.waiting-container h1 {
margin-bottom: 1rem;
}
.waiting-container p {
color: #666;
font-size: 1.1rem;
margin-bottom: 2rem;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
:global(body) {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.join-container {
max-width: 400px;
margin: 0 auto;
padding: 2rem;
text-align: center;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
}
h1 {
color: #333;
margin-bottom: 2rem;
}
.join-form {
background: #fff;
border: 2px solid #ddd;
border-radius: 8px;
padding: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
text-align: left;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
color: #555;
}
input[type="text"] {
width: 100%;
padding: 1rem;
font-size: 1.5rem;
text-align: center;
text-transform: uppercase;
border: 2px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
letter-spacing: 0.25rem;
}
.error {
color: #dc3545;
margin-bottom: 1rem;
padding: 0.75rem;
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
}
.join-button {
width: 100%;
padding: 1rem;
font-size: 1.25rem;
font-weight: bold;
background: #007bff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.join-button:hover {
background: #0056b3;
}
.join-button:active {
background: #004085;
}
.waiting-container {
text-align: center;
padding: 4rem 2rem;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.waiting-container h1 {
margin-bottom: 1rem;
}
.waiting-container p {
color: #666;
font-size: 1.1rem;
margin-bottom: 2rem;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #007bff;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
src/routes/master/+page.svelte +331 -424
diff --git a/src/routes/master/+page.svelte b/src/routes/master/+page.svelte
index 9caf18e..14e8dc1 100644
@@ -1,431 +1,338 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { io, type Socket } from 'socket.io-client';
import { PLAYER_COLORS, type Player, type GameConfig } from '$lib/types';
import GameScreen from '$lib/components/GameScreen.svelte';
import ResultsScreen from '$lib/components/ResultsScreen.svelte';
let socket: Socket | null = null;
let roomCode = $state('');
let connectedDisplays = $state(0);
let displayIds = $state<string[]>([]);
let masterSocketId = $state<string>('');
// Game configuration
let numPlayers = $state(2);
let gameDuration = $state(60);
let players = $state<Player[]>([]);
// Game state
let gameState = $state<'setup' | 'playing' | 'results'>('setup');
let currentDisplayColor = $state<string>('');
let currentPlayerId = $state<number | null>(null);
let currentTargetId = $state<string>(''); // Track which display is currently showing color
let scores = $state<Player[]>([]);
onMount(() => {
// Connect to signaling server
// In production, Socket.IO runs on same server as SvelteKit
// In development, it runs on separate port (3001)
const socketUrl = import.meta.env.DEV ? 'http://localhost:3001' : window.location.origin;
socket = io(socketUrl);
socket.on('connect', () => {
console.log('Connected to server');
// Store master's socket ID
if (socket?.id) {
masterSocketId = socket.id;
}
// Create a room
socket?.emit('create-room', (response: any) => {
if (response.success) {
roomCode = response.roomCode;
}
});
});
socket.on('display-joined', ({ displayId, totalDisplays }: any) => {
console.log('Display joined:', displayId);
connectedDisplays = totalDisplays;
displayIds = [...displayIds, displayId];
});
socket.on('display-left', ({ displayId, totalDisplays }: any) => {
console.log('Display left:', displayId);
connectedDisplays = totalDisplays;
displayIds = displayIds.filter((id) => id !== displayId);
});
socket.on('display-action', ({ from, action }: any) => {
console.log('Action from display:', from, action);
if (action.type === 'tap') {
handleTap(action.playerId);
}
});
socket.on('room-closed', () => {
console.log('Room closed');
// Handle room closure
});
});
onDestroy(() => {
socket?.disconnect();
});
function updatePlayers() {
players = Array.from({ length: numPlayers }, (_, i) => ({
id: i,
color: PLAYER_COLORS[i % PLAYER_COLORS.length],
score: 0
}));
}
$effect(() => {
updatePlayers();
});
async function startGame() {
// Initialize scores
scores = players.map((p) => ({ ...p, score: 0 }));
// Transition to game state
gameState = 'playing';
// Request wake lock on this device
try {
await requestWakeLock();
} catch (e) {
console.error('Wake lock failed:', e);
}
// Tell all displays to start the game
socket?.emit('game-broadcast', {
roomCode,
command: {
type: 'start-game',
duration: gameDuration,
players: players
}
});
// Start the game loop
runGameLoop();
}
let wakeLock: WakeLockSentinel | null = null;
async function requestWakeLock() {
if ('wakeLock' in navigator) {
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => {
console.log('Wake lock released');
});
} catch (err) {
console.error('Wake lock error:', err);
}
}
}
function releaseWakeLock() {
if (wakeLock) {
wakeLock.release();
wakeLock = null;
}
}
function runGameLoop() {
const startTime = Date.now();
const endTime = startTime + gameDuration * 1000;
function loop() {
const now = Date.now();
if (now >= endTime) {
endGame();
return;
}
// Random delay between color shows (500ms to 3000ms)
const delay = Math.random() * 2500 + 500;
setTimeout(() => {
showRandomColor();
loop();
}, delay);
}
loop();
}
function showRandomColor() {
// Include master in the pool of possible displays
const allDisplays = [masterSocketId, ...displayIds];
if (allDisplays.length === 0) return;
// Pick a random display (including master)
const randomDisplayId = allDisplays[Math.floor(Math.random() * allDisplays.length)];
// Pick a random player
const randomPlayer = players[Math.floor(Math.random() * players.length)];
// Track current target
currentTargetId = randomDisplayId;
// Check if master is selected
if (randomDisplayId === masterSocketId) {
// Show color on master's own screen
currentDisplayColor = randomPlayer.color;
currentPlayerId = randomPlayer.id;
} else {
// Send command to display device
socket?.emit('game-command', {
to: randomDisplayId,
command: {
type: 'show-color',
playerId: randomPlayer.id,
color: randomPlayer.color
}
});
}
// Auto-hide after 2 seconds if not tapped
setTimeout(() => {
if (currentTargetId === masterSocketId) {
// Hide color on master
currentDisplayColor = '';
currentPlayerId = null;
} else {
// Send hide command to display
socket?.emit('game-command', {
to: randomDisplayId,
command: {
type: 'hide-color'
}
});
}
currentTargetId = '';
}, 2000);
}
function handleTap(playerId: number) {
// Increment score
const player = scores.find((p) => p.id === playerId);
if (player) {
player.score++;
scores = [...scores];
}
}
function handleMasterTap() {
if (currentPlayerId !== null && currentDisplayColor) {
// Score the point
handleTap(currentPlayerId);
// Hide color immediately
currentDisplayColor = '';
currentPlayerId = null;
currentTargetId = '';
}
}
function endGame() {
gameState = 'results';
releaseWakeLock();
// Send results to all displays
socket?.emit('game-broadcast', {
roomCode,
command: {
type: 'show-results',
scores: scores
}
});
}
function restartGame() {
gameState = 'setup';
scores = [];
}
function quitGame() {
socket?.disconnect();
// Redirect to home
window.location.href = '/';
}
import { onMount, onDestroy } from "svelte";
import { io, type Socket } from "socket.io-client";
import { PLAYER_COLORS, type Player, type GameConfig } from "$lib/types";
import GameScreen from "$lib/components/GameScreen.svelte";
import ResultsScreen from "$lib/components/ResultsScreen.svelte";
let socket: Socket | null = null;
let roomCode = $state("");
let connectedDisplays = $state(0);
let displayIds = $state<string[]>([]);
let masterSocketId = $state<string>("");
// Game configuration
let playerCount = $state(2);
let gameDuration = $state(60);
let players = $state<Player[]>([]);
// Game state
let gameState = $state<"setup" | "playing" | "results">("setup");
let scores = $state<Player[]>([]);
onMount(() => {
// Connect to signaling server
// In production, Socket.IO runs on same server as SvelteKit
// In development, it runs on separate port (3001)
const socketUrl = import.meta.env.DEV ? "http://localhost:3001" : window.location.origin;
socket = io(socketUrl);
socket.on("connect", () => {
console.log("Connected to server");
// Store master's socket ID
if (socket?.id) {
masterSocketId = socket.id;
}
// Create a room
socket?.emit("create-room", (response: any) => {
if (response.success) {
roomCode = response.roomCode;
}
});
});
socket.on("display-joined", ({ displayId, totalDisplays }: any) => {
console.log("Display joined:", displayId);
connectedDisplays = totalDisplays;
displayIds = [...displayIds, displayId];
});
socket.on("display-left", ({ displayId, totalDisplays }: any) => {
console.log("Display left:", displayId);
connectedDisplays = totalDisplays;
displayIds = displayIds.filter((id) => id !== displayId);
});
socket.on("display-action", ({ from, action }: any) => {
console.log("Action from display:", from, action);
if (action.type === "tap") {
handleTap(action.playerId);
}
});
socket.on("room-closed", () => {
console.log("Room closed");
// Handle room closure
});
});
onDestroy(() => {
socket?.disconnect();
});
function updatePlayers() {
players = Array.from({ length: playerCount }, (_, i) => ({
id: i,
color: PLAYER_COLORS[i % PLAYER_COLORS.length],
score: 0,
}));
}
$effect(() => {
updatePlayers();
});
async function startGame() {
// Initialize scores
scores = players.map((p) => ({ ...p, score: 0 }));
// Transition to game state
gameState = "playing";
// Request wake lock on this device
try {
await requestWakeLock();
} catch (e) {
console.error("Wake lock failed:", e);
}
// Tell all displays to start the game
socket?.emit("game-broadcast", {
roomCode,
command: {
type: "start-game",
duration: gameDuration,
players: players,
},
});
// Start the game loop
runGameLoop();
}
let wakeLock: WakeLockSentinel | null = null;
async function requestWakeLock() {
if ("wakeLock" in navigator) {
try {
wakeLock = await navigator.wakeLock.request("screen");
wakeLock.addEventListener("release", () => {
console.log("Wake lock released");
});
} catch (err) {
console.error("Wake lock error:", err);
}
}
}
function releaseWakeLock() {
if (wakeLock) {
wakeLock.release();
wakeLock = null;
}
}
function runGameLoop() {
setTimeout(() => {
endGame();
}, gameDuration * 1000);
}
function handleTap(playerId: number) {
// Increment score
const player = scores.find((p) => p.id === playerId);
if (player) {
player.score++;
scores = [...scores];
}
}
function endGame() {
gameState = "results";
releaseWakeLock();
// Send results to all displays
socket?.emit("game-broadcast", {
roomCode,
command: {
type: "show-results",
scores: scores,
},
});
}
function restartGame() {
gameState = "setup";
scores = [];
}
function quitGame() {
socket?.disconnect();
// Redirect to home
window.location.href = "/";
}
</script>
{#if gameState === 'setup'}
<div class="setup-container">
<h1>Master Device</h1>
{#if roomCode}
<div class="room-code">
<h2>Room Code</h2>
<div class="code">{roomCode}</div>
</div>
<div class="display-count">
<h3>Connected Displays: {connectedDisplays}</h3>
</div>
<div class="config">
<h3>Game Configuration</h3>
<div class="form-group">
<label for="numPlayers">Number of Players:</label>
<input
type="number"
id="numPlayers"
bind:value={numPlayers}
min="1"
max="10"
/>
</div>
<div class="form-group">
<label for="duration">Game Duration (seconds):</label>
<input
type="number"
id="duration"
bind:value={gameDuration}
min="10"
max="300"
/>
</div>
<div class="players-preview">
<h4>Players:</h4>
<div class="player-colors">
{#each players as player}
<div class="player-color" style="background-color: {player.color}">
Player {player.id + 1}
</div>
{/each}
</div>
</div>
<button class="start-button" onclick={startGame}>Start Game</button>
</div>
{:else}
<p>Connecting to server...</p>
{/if}
</div>
{:else if gameState === 'playing'}
<GameScreen color={currentDisplayColor} isMaster={true} onTap={handleMasterTap} />
{:else if gameState === 'results'}
<ResultsScreen {scores} isMaster={true} onRestart={restartGame} onQuit={quitGame} />
{#if gameState === "setup"}
<div class="setup-container">
<h1>Master Device</h1>
{#if roomCode}
<div class="room-code">
<h2>Room Code</h2>
<div class="code">{roomCode}</div>
</div>
<div class="display-count">
<h3>Connected Displays: {connectedDisplays}</h3>
</div>
<div class="config">
<h3>Game Configuration</h3>
<div class="form-group">
<label for="numPlayers">Number of Players:</label>
<input type="number" id="numPlayers" bind:value={playerCount} min="1" max="10" />
</div>
<div class="form-group">
<label for="duration">Game Duration (seconds):</label>
<input type="number" id="duration" bind:value={gameDuration} min="10" max="300" />
</div>
<div class="players-preview">
<h4>Players:</h4>
<div class="player-colors">
{#each players as player}
<div class="player-color" style="background-color: {player.color}">
Player {player.id + 1}
</div>
{/each}
</div>
</div>
<button class="start-button" onclick={startGame}>Start Game</button>
</div>
{:else}
<p>Connecting to server...</p>
{/if}
</div>
{:else if gameState === "playing"}
<GameScreen {playerCount} onTap={handleTap} />
{:else if gameState === "results"}
<ResultsScreen {scores} isMaster={true} onRestart={restartGame} onQuit={quitGame} />
{/if}
<style>
:global(body) {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.setup-container {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
h1 {
color: #333;
margin-bottom: 2rem;
}
.room-code {
background: #f0f0f0;
padding: 2rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.code {
font-size: 3rem;
font-weight: bold;
letter-spacing: 0.5rem;
color: #007bff;
margin-top: 1rem;
}
.display-count {
margin-bottom: 2rem;
}
.display-count h3 {
color: #666;
}
.config {
background: #fff;
border: 2px solid #ddd;
border-radius: 8px;
padding: 2rem;
margin-top: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
text-align: left;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
color: #555;
}
input[type='number'] {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
border: 2px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.players-preview {
margin-top: 2rem;
text-align: left;
}
.player-colors {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.player-color {
padding: 0.75rem 1.5rem;
border-radius: 4px;
color: white;
font-weight: bold;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.start-button {
width: 100%;
padding: 1rem;
font-size: 1.25rem;
font-weight: bold;
background: #28a745;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
margin-top: 2rem;
transition: background 0.2s;
}
.start-button:hover {
background: #218838;
}
.start-button:active {
background: #1e7e34;
}
:global(body) {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.setup-container {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
h1 {
color: #333;
margin-bottom: 2rem;
}
.room-code {
background: #f0f0f0;
padding: 2rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.code {
font-size: 3rem;
font-weight: bold;
letter-spacing: 0.5rem;
color: #007bff;
margin-top: 1rem;
}
.display-count {
margin-bottom: 2rem;
}
.display-count h3 {
color: #666;
}
.config {
background: #fff;
border: 2px solid #ddd;
border-radius: 8px;
padding: 2rem;
margin-top: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
text-align: left;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
color: #555;
}
input[type="number"] {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
border: 2px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.players-preview {
margin-top: 2rem;
text-align: left;
}
.player-colors {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.player-color {
padding: 0.75rem 1.5rem;
border-radius: 4px;
color: white;
font-weight: bold;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.start-button {
width: 100%;
padding: 1rem;
font-size: 1.25rem;
font-weight: bold;
background: #28a745;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
margin-top: 2rem;
transition: background 0.2s;
}
.start-button:hover {
background: #218838;
}
.start-button:active {
background: #1e7e34;
}
</style>
svelte.config.js +1 -1
diff --git a/svelte.config.js b/svelte.config.js
index 06810da..8184d0c 100644
@@ -9,7 +9,7 @@ const config = {
kit: {
adapter: adapter({
out: 'build'
out: "build",
}),
},
};