.claude/settings.local.json
+12
-0
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..6de25aa
@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(npm install:*)",
"Bash(npm run check:*)",
"Bash(npm uninstall:*)",
"Bash(npm run build:*)"
],
"deny": [],
"ask": []
}
}
.dockerignore
+46
-0
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..ce42ce2
@@ -0,0 +1,46 @@
# Dependencies
node_modules/
# Build outputs
build/
.svelte-kit/
dist/
# Development files
.git/
.gitignore
.vscode/
.idea/
# Environment files
.env
.env.*
!.env.example
# Logs
logs/
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS files
.DS_Store
Thumbs.db
# Testing
coverage/
# Documentation
README.md
*.md
# Docker files
Dockerfile
.dockerignore
docker-compose.yml
# CI/CD
.github/
.gitlab-ci.yml
Dockerfile
+51
-0
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..dd95691
@@ -0,0 +1,51 @@
# Multi-stage build for Stop It! game
# Stage 1: Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install ALL dependencies (including devDependencies for build)
RUN npm ci
# Copy source code
COPY . .
# Build the SvelteKit application
RUN npm run build
# Prune dev dependencies
RUN npm prune --production
# Stage 2: Production stage
FROM node:20-alpine AS runtime
WORKDIR /app
# Copy package files
COPY package*.json ./
# Copy production dependencies from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/build ./build
# Copy server code
COPY --from=builder /app/server ./server
# Expose port 3000
EXPOSE 3000
# Set NODE_ENV to production
ENV NODE_ENV=production
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Start the unified server
CMD ["node", "server/index.js"]
README.md
+178
-21
diff --git a/README.md b/README.md
index 75842c4..3297bdc 100644
@@ -1,38 +1,195 @@
# sv
# Stop It! - Multi-Device Reaction Game
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
A real-time multiplayer reaction game that runs across multiple devices using WebRTC. One device acts as the "master" controller, while other devices serve as displays and input devices.
## Creating a project
## Features
If you're seeing this, you've probably already done this step. Congrats!
- **Multi-Device Gameplay**: Connect multiple phones/tablets to play together
- **WebRTC Communication**: Peer-to-peer connections for low-latency gameplay
- **Room-Based System**: Simple 6-character room codes for easy joining
- **Configurable Games**: Set number of players (2-10) and game duration (10-300 seconds)
- **Screen Wake Lock**: Keeps screens active during gameplay
- **Real-time Scoring**: Track points for each player with live updates
- **Beautiful UI**: Gradient backgrounds, smooth animations, and responsive design
```sh
# create a new project in the current directory
npx sv create
## How to Play
# create a new project in my-app
npx sv create my-app
```
1. **Master Device**: One player opens the app and clicks "Create Game"
- A unique room code is displayed
- Configure the number of players and game duration
- Wait for other devices to connect
- Click "Start Game" when ready
2. **Display Devices**: Other players click "Join Game"
- Enter the 6-character room code shown on the master device
- Wait for the master to start the game
3. **During the Game**:
- Random player colors appear on random device screens
- Tap the screen as quickly as possible when your color appears!
- Each successful tap scores 1 point
- Colors disappear if not tapped within 2 seconds
- Game continues for the configured duration
4. **Results**: After the game ends, scores are displayed on all devices
- The master device can restart the game or quit
## Technology Stack
## Developing
- **Frontend**: SvelteKit + Svelte 5 (with runes)
- **Backend**: Node.js + Socket.io for signaling
- **WebRTC**: simple-peer for peer-to-peer connections
- **Build Tool**: Vite 7
- **Language**: TypeScript
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
## Installation
```sh
1. Clone the repository:
```bash
git clone <repository-url>
cd stop-it
```
2. Install dependencies:
```bash
npm install
```
## Running the Game
Start both the signaling server and the SvelteKit dev server:
```bash
npm run dev
```
This will start:
- **SvelteKit dev server** on `http://localhost:5173`
- **Signaling server** on `http://localhost:3001`
Open `http://localhost:5173` in multiple browsers/devices to play!
## Development Scripts
- `npm run dev` - Start both servers concurrently
- `npm run dev:vite` - Start only the SvelteKit dev server
- `npm run dev:server` - Start only the signaling server
- `npm run build` - Build for production
- `npm run preview` - Preview production build
- `npm run check` - Run type checking
## Project Structure
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
stop-it/
├── server.js # WebSocket signaling server
├── src/
│ ├── routes/
│ │ ├── +page.svelte # Home page (game selection)
│ │ ├── master/
│ │ │ └── +page.svelte # Master device page
│ │ └── join/
│ │ └── +page.svelte # Display device join page
│ ├── lib/
│ │ ├── types.ts # TypeScript type definitions
│ │ ├── webrtc.ts # WebRTC connection management
│ │ └── components/
│ │ ├── GameScreen.svelte # Shared game display
│ │ └── ResultsScreen.svelte # Results display
│ └── app.d.ts # Global type declarations
├── package.json
└── README.md
```
## How It Works
### Architecture
1. **Signaling Server** (`server.js`):
- Manages rooms and room codes
- Facilitates WebRTC connection setup between devices
- Routes signaling messages (offers, answers, ICE candidates)
## Building
2. **Master Device**:
- Creates a room and generates a unique code
- Establishes WebRTC connections to all display devices
- Controls game flow and timing
- Sends color display commands to specific devices
- Tracks scores and announces results
To create a production version of your app:
3. **Display Devices**:
- Join a room using a code
- Connect to the master via WebRTC
- Display colors when commanded
- Send tap events back to the master
- Receive and display final scores
```sh
npm run build
### WebRTC Flow
1. Display device joins a room via Socket.io
2. Signaling server notifies the master
3. Master initiates WebRTC connection (sends offer)
4. Display receives offer and sends answer
5. ICE candidates are exchanged via signaling server
6. Direct P2P connection is established
7. Game commands and data flow through WebRTC data channels
## Network Requirements
- All devices must be able to reach the signaling server
- For devices on different networks, you may need TURN servers (currently using STUN only)
- Default STUN servers:
- `stun:stun.l.google.com:19302`
- `stun:global.stun.twilio.com:3478`
## Browser Compatibility
Works best on modern browsers with WebRTC support:
- Chrome/Edge (Desktop & Mobile)
- Firefox (Desktop & Mobile)
- Safari (Desktop & Mobile - iOS 11+)
**Note**: Screen Wake Lock API may not be supported on all browsers.
## Deployment
### Production Build
1. Build the SvelteKit app:
```bash
npm run build
```
2. Run the production server and signaling server together
### Environment Variables
You can configure the signaling server port:
```bash
PORT=3001 node server.js
```
You can preview the production build with `npm run preview`.
Update the socket.io connection URLs in:
- `src/routes/master/+page.svelte`
- `src/routes/join/+page.svelte`
## Future Enhancements
- [ ] Add sound effects for taps and game events
- [ ] Implement player names instead of just numbers
- [ ] Add different game modes (speed round, elimination, etc.)
- [ ] Persistent leaderboards
- [ ] Custom color themes
- [ ] Replay functionality
- [ ] Add TURN server support for better NAT traversal
- [ ] Add animations for color transitions
- [ ] Support for larger player counts
## License
MIT
## Contributing
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
Contributions are welcome! Please feel free to submit a Pull Request.
package-lock.json
+1669
-57
Large diff (1726 lines changed) - not displayed
package.json
+13
-3
diff --git a/package.json b/package.json
index 7833843..53a2e40 100644
@@ -4,22 +4,32 @@
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"dev": "concurrently \"npm run dev:vite\" \"npm run dev:server\"",
"dev:vite": "vite dev",
"dev:server": "node server.js",
"build": "vite build",
"start": "node server/index.js",
"preview": "npm run build && npm run start",
"format": "prettier --write .",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"concurrently": "^9.2.1",
"prettier": "^3.7.4",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"typescript": "^5.9.3",
"vite": "^7.2.6"
},
"dependencies": {
"express": "^5.2.1",
"nanoid": "^5.1.6",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3"
}
}
server.js
+133
-0
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..d013b17
@@ -0,0 +1,133 @@
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']
}
});
// Room state management
const rooms = new Map();
// Map of socket IDs to room codes
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;
}
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}`);
});
server/index.js
+145
-0
diff --git a/server/index.js b/server/index.js
new file mode 100644
index 0000000..31c4d17
@@ -0,0 +1,145 @@
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();
const httpServer = createServer(app);
// Initialize Socket.IO
const io = new Server(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST']
}
});
// Room state management
const rooms = new Map();
// Map of socket IDs to room codes
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;
}
// 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);
}
});
});
// SvelteKit handler MUST be added last
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`);
});
src/app.d.ts
+13
-0
diff --git a/src/app.d.ts b/src/app.d.ts
index 520c421..c13f2d8 100644
@@ -8,6 +8,19 @@ declare global {
// interface PageState {}
// interface Platform {}
}
// Wake Lock API types
interface WakeLockSentinel extends EventTarget {
readonly released: boolean;
readonly type: 'screen';
release(): Promise<void>;
}
interface Navigator {
wakeLock?: {
request(type: 'screen'): Promise<WakeLockSentinel>;
};
}
}
export {};
src/lib/components/GameScreen.svelte
+71
-0
diff --git a/src/lib/components/GameScreen.svelte b/src/lib/components/GameScreen.svelte
new file mode 100644
index 0000000..323da47
@@ -0,0 +1,71 @@
<script lang="ts">
interface Props {
color: string;
isMaster: boolean;
onTap?: () => void;
}
let { color = '', isMaster = false, onTap }: Props = $props();
function handleClick() {
if (color && onTap) {
onTap();
}
}
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"
>
{#if color}
<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;
}
.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;
}
}
</style>
src/lib/components/ResultsScreen.svelte
+208
-0
diff --git a/src/lib/components/ResultsScreen.svelte b/src/lib/components/ResultsScreen.svelte
new file mode 100644
index 0000000..788a051
@@ -0,0 +1,208 @@
<script lang="ts">
import type { Player } from '$lib/types';
interface Props {
scores: Player[];
isMaster: boolean;
onRestart?: () => void;
onQuit?: () => void;
}
let { scores = [], isMaster = false, onRestart, onQuit }: Props = $props();
// Sort players by score (descending)
const sortedScores = $derived([...scores].sort((a, b) => b.score - a.score));
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>
<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;
}
</style>
src/lib/types.ts
+35
-0
diff --git a/src/lib/types.ts b/src/lib/types.ts
new file mode 100644
index 0000000..e0622aa
@@ -0,0 +1,35 @@
export interface Player {
id: number;
color: string;
score: number;
}
export interface GameConfig {
numPlayers: number;
duration: number; // in seconds
}
export interface GameCommand {
type: 'show-color' | 'hide-color' | 'game-over' | 'show-results';
playerId?: number;
color?: string;
scores?: Player[];
}
export interface DisplayDevice {
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
];
src/routes/+layout.svelte
+2
-0
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index ba2f22c..9497542 100644
@@ -1,4 +1,6 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
const { children } = $props();
</script>
src/routes/+page.svelte
+190
-2
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index cc88df0..3af2f55 100644
@@ -1,2 +1,190 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script lang="ts">
import { goto } from '$app/navigation';
function createRoom() {
goto('/master');
}
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>
<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;
}
}
</style>
src/routes/join/+page.svelte
+284
-0
diff --git a/src/routes/join/+page.svelte b/src/routes/join/+page.svelte
new file mode 100644
index 0000000..21ced44
@@ -0,0 +1,284 @@
<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;
}
}
</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}
<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); }
}
</style>
src/routes/master/+page.svelte
+431
-0
diff --git a/src/routes/master/+page.svelte b/src/routes/master/+page.svelte
new file mode 100644
index 0000000..9caf18e
@@ -0,0 +1,431 @@
<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 = '/';
}
</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}
<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;
}
</style>
svelte.config.js
+4
-5
diff --git a/svelte.config.js b/svelte.config.js
index a8bb58a..06810da 100644
@@ -1,4 +1,4 @@
import adapter from "@sveltejs/adapter-auto";
import adapter from "@sveltejs/adapter-node";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
@@ -8,10 +8,9 @@ const config = {
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(),
adapter: adapter({
out: 'build'
}),
},
};