mid switch to Colyseum

This commit is contained in:
Shaheed Azaad
2025-07-17 14:29:55 +02:00
parent e9565471cb
commit c55454cf3d
21 changed files with 4269 additions and 50 deletions

View File

@@ -1,11 +1,4 @@
---
alwaysApply: true
---
# Structure
- Read, but don't edit @README.md to get an idea of how the app should work.
- Where possible, the app should be separated into an API and frontend components.
- Tests should be written as the app is developed.
- Use package.json to ensure you're using the correct packages to implement features and not installing redundant packages or reinventing the wheel.
- User superforms for all forms
- For now, we will keep multiplayer and single player experiment serving, queuing, and session logic completely separate, even if it means overlapping and redundant code. We will optimise later.
Read, but don't edit @CLAUDE.md for all prompts

View File

@@ -1,9 +0,0 @@
---
description: Code rules
globs:
---
# Code
- This app uses Sveltekit. Where possible, the app should use reusable components and Svelte features for reactivity.
- This app is written in Typescript.

View File

@@ -1,8 +0,0 @@
---
description: Multiplayer experiment implementation
globs:
alwaysApply: false
---
- Multiplayer experiments should be 'run' by the app, not on the participant's computers like singleplayer experiments. This enables participants to drop connection and rejoin the session where they left off
- All participants should see the same thing at the same time, but *later* I want to give the experimenter the ability to occasionally show participants different things based on a URL variable that this app will inject based on some . So participants will be on the same 'screen' but with different images, for example. The ability for experimenters to show different things should not be implemented unless directly requested, but any solutions to the basic multiplayer implementation consider that this needs to be possible in the future.

View File

@@ -1,10 +0,0 @@
---
description: Implementing database functions
globs:
---
# Your rule content
- This app uses DrizzleORM
- The app should use transactions and connection pooling where possible
- Utmost care needs to be taken to prevent SQL injection

View File

@@ -1,9 +0,0 @@
---
description: Styling
globs:
---
# Styling
- Everything should be designed using shadcn for svelte components
= No plain CSS should be written - only Tailwind

91
CLAUDE.md Normal file
View File

@@ -0,0 +1,91 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
cog-socket is a platform for hosting jsPsych and Pavlovia experiments with multiplayer support using WebSockets. Built with SvelteKit, it supports both single-player and multiplayer psychological experiments.
## Development Commands
### Database
- `npm run db:start` - Start PostgreSQL server via Docker Compose
- `npm run db:push` - Push schema changes to database
- `npm run db:migrate` - Run database migrations
- `npm run db:studio` - Open Drizzle Studio for database management
### Development Server
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run preview` - Preview production build
### Multiplayer Server
- `npm run multiplayer:dev` - Start multiplayer server in watch mode
- `npm run multiplayer:start` - Start multiplayer server in production mode
### Testing & Quality
- `npm run test` - Run all tests (unit + e2e)
- `npm run test:unit` - Run unit tests with Vitest
- `npm run test:e2e` - Run end-to-end tests with Playwright
- `npm run check` - Run svelte-check for TypeScript checking
- `npm run lint` - Run linting (Prettier + ESLint)
- `npm run format` - Format code with Prettier
## Architecture Overview
### Database Schema (DrizzleORM + PostgreSQL)
- **Users**: Authentication with Lucia, supports admin/user roles
- **Experiments**: jsPsych or PsychoJS experiments with multiplayer flag
- **Sessions**: User authentication sessions
- **ExperimentSessions**: Individual experiment runs with status tracking
### Multiplayer System (Colyseus)
- **Standalone server**: Runs on separate port (8080) from main app
- **ExperimentRoom**: Manages participant queuing, state synchronization
- **Session management**: Handles participant connections, disconnections, and queuing
- **Real-time communication**: WebSocket-based experiment state sharing
### SvelteKit Structure
- **Routes**:
- `/experiment/[id]` - Single experiment management
- `/public/multiplayer/run/[experimentId]` - Multiplayer experiment runner
- `/public/run/[experimentId]` - Single-player experiment runner
- `/api/experiments/[experimentId]` - Experiment API endpoints
- **Authentication**: Lucia-based session management with cookies
- **File handling**: S3 integration for experiment assets
### Key Components
- **ExperimentRoom.ts**: Core multiplayer room logic with participant management
- **ExperimentRoomState.ts**: Colyseus schema for synchronized state
- **colyseusServer.ts**: Multiplayer server setup and room management
- **sessionManager.ts**: Bridges SvelteKit app with Colyseus server
## Development Notes
### Multiplayer Development
- Multiplayer server runs independently on port 8080
- Experiments must have `multiplayer: true` flag in database
- Room capacity determined by `maxParticipants` field
- Queue system handles overflow participants
### Authentication
- Default admin user (admin/admin) created in development
- Session tokens stored in cookies, managed by Lucia
- Authentication middleware in `hooks.server.ts`
### Database Operations
- Use DrizzleORM for all database operations
- Schema defined in `src/lib/server/db/schema.ts`
- Migrations managed through drizzle-kit
### File Structure
- UI components in `src/lib/components/ui/` (bits-ui based)
- Server logic in `src/lib/server/`
- Route handlers follow SvelteKit conventions
- Multiplayer code isolated in `src/lib/server/multiplayer/`
## Testing Setup
- Unit tests: Vitest with Svelte browser testing
- E2E tests: Playwright
- TypeScript checking: svelte-check
- Linting: ESLint + Prettier with Svelte plugins

2576
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,9 @@
"db:start": "docker compose up",
"db:push": "drizzle-kit push",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"multiplayer:dev": "tsx --watch src/lib/server/multiplayer/standalone.ts",
"multiplayer:start": "tsx src/lib/server/multiplayer/standalone.ts"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
@@ -48,6 +50,7 @@
"sveltekit-superforms": "^2.27.1",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.0.0",
"tsx": "^4.20.3",
"tw-animate-css": "^1.3.5",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
@@ -58,6 +61,9 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.844.0",
"@colyseus/core": "^0.16.19",
"@colyseus/schema": "^3.0.42",
"@colyseus/ws-transport": "^0.16.5",
"@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
@@ -65,6 +71,8 @@
"bits-ui": "^2.8.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"colyseus": "^0.16.4",
"colyseus.js": "^0.16.19",
"dotenv": "^17.2.0",
"drizzle-orm": "^0.40.0",
"lucia": "^3.2.2",

View File

@@ -26,6 +26,7 @@ export const experiment = pgTable('experiment', {
.references(() => user.id),
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull(),
multiplayer: boolean('multiplayer').default(false).notNull(),
maxParticipants: integer('max_participants').default(1).notNull(),
type: experimentTypeEnum('type').notNull()
});

View File

@@ -0,0 +1,197 @@
import { Server, matchMaker } from 'colyseus';
import { ExperimentRoom } from './rooms/ExperimentRoom.js';
import { WebSocketTransport } from '@colyseus/ws-transport';
import { db } from './db.js';
import * as schema from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { createServer } from 'http';
export class MultiplayerServer {
private server: Server;
private port: number;
constructor(port: number = 8080) {
this.port = port;
this.server = new Server({
transport: new WebSocketTransport({
pingInterval: 30000,
pingMaxRetries: 3
}),
presence: undefined, // Use default in-memory presence
driver: undefined, // Use default in-memory driver
gracefullyShutdown: false
});
// Add CORS middleware using Colyseus's built-in middleware support
this.server.use(undefined, (req, res, next) => {
const origin = req.headers.origin;
const isDev = process.env.NODE_ENV !== 'production';
// Always set CORS headers for development
if (isDev) {
res.setHeader('Access-Control-Allow-Origin', '*');
} else {
const allowedOrigins = ['https://your-production-domain.com']; // Update with actual production domain
if (origin && allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
res.setHeader('Access-Control-Allow-Credentials', 'true');
// Handle preflight requests
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
next();
});
this.setupRooms();
}
private setupRooms() {
// Define the ExperimentRoom
this.server.define('experiment_room', ExperimentRoom);
// Set up matchmaking
this.server.onShutdown(() => {
console.log('[MultiplayerServer] Server shutting down...');
});
}
async start() {
try {
await this.server.listen(this.port);
console.log(`[MultiplayerServer] Colyseus server listening on port ${this.port}`);
} catch (error) {
console.error('[MultiplayerServer] Failed to start server:', error);
throw error;
}
}
async stop() {
try {
await this.server.gracefullyShutdown();
console.log('[MultiplayerServer] Server stopped gracefully');
} catch (error) {
console.error('[MultiplayerServer] Error stopping server:', error);
throw error;
}
}
getServer() {
return this.server;
}
// Utility method to find or create a room for an experiment
async findOrCreateRoom(experimentId: string, clientOptions: any = {}) {
try {
// Get experiment details to determine max participants
const experiment = await db.query.experiment.findFirst({
where: eq(schema.experiment.id, experimentId)
});
if (!experiment) {
throw new Error(`Experiment ${experimentId} not found`);
}
if (!experiment.multiplayer) {
throw new Error(`Experiment ${experimentId} is not a multiplayer experiment`);
}
// Try to find existing room for this experiment
const rooms = await matchMaker.query({
name: 'experiment_room',
options: { experimentId }
});
// Find a room that has space or is waiting
let availableRoom = rooms.find(room =>
room.clients < experiment.maxParticipants &&
room.metadata?.status !== 'completed'
);
if (!availableRoom) {
// Create new room
console.log(`[MultiplayerServer] Creating new room for experiment ${experimentId}`);
availableRoom = await matchMaker.createRoom('experiment_room', {
experimentId,
maxParticipants: experiment.maxParticipants
});
}
return availableRoom;
} catch (error) {
console.error('[MultiplayerServer] Error finding/creating room:', error);
throw error;
}
}
// Get room status
async getRoomStatus(roomId: string) {
try {
const room = matchMaker.getRoomById(roomId);
if (!room) {
return null;
}
return {
roomId: room.roomId,
clients: room.clients,
maxClients: room.maxClients,
metadata: room.metadata
};
} catch (error) {
console.error('[MultiplayerServer] Error getting room status:', error);
return null;
}
}
// Get all rooms for an experiment
async getExperimentRooms(experimentId: string) {
try {
const rooms = await matchMaker.query({
name: 'experiment_room',
options: { experimentId }
});
return rooms.map(room => ({
roomId: room.roomId,
clients: room.clients,
maxClients: room.maxClients,
metadata: room.metadata
}));
} catch (error) {
console.error('[MultiplayerServer] Error getting experiment rooms:', error);
return [];
}
}
}
// Singleton instance
let multiplayerServer: MultiplayerServer | null = null;
export function getMultiplayerServer(port: number = 8080): MultiplayerServer {
if (!multiplayerServer) {
multiplayerServer = new MultiplayerServer(port);
}
return multiplayerServer;
}
export function startMultiplayerServer(port: number = 8080): Promise<MultiplayerServer> {
const server = getMultiplayerServer(port);
return server.start().then(() => server);
}
export function stopMultiplayerServer(): Promise<void> {
if (multiplayerServer) {
return multiplayerServer.stop();
}
return Promise.resolve();
}

View File

@@ -0,0 +1,13 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from '../db/schema.js';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
// Database connection
const client = postgres(process.env.DATABASE_URL || 'postgresql://postgres:password@localhost:5432/postgres');
export const db = drizzle(client, { schema });
console.log('[Multiplayer DB] Connected to database');

View File

@@ -0,0 +1,40 @@
import { startMultiplayerServer, getMultiplayerServer } from './colyseusServer.js';
const PORT = parseInt(process.env.MULTIPLAYER_PORT || '8080');
// Start the multiplayer server
startMultiplayerServer(PORT)
.then((server) => {
console.log(`[Multiplayer] Server started successfully on port ${PORT}`);
})
.catch((error) => {
console.error('[Multiplayer] Failed to start server:', error);
process.exit(1);
});
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('[Multiplayer] Received SIGINT, shutting down gracefully...');
try {
const server = getMultiplayerServer();
await server.stop();
console.log('[Multiplayer] Server stopped gracefully');
process.exit(0);
} catch (error) {
console.error('[Multiplayer] Error during shutdown:', error);
process.exit(1);
}
});
process.on('SIGTERM', async () => {
console.log('[Multiplayer] Received SIGTERM, shutting down gracefully...');
try {
const server = getMultiplayerServer();
await server.stop();
console.log('[Multiplayer] Server stopped gracefully');
process.exit(0);
} catch (error) {
console.error('[Multiplayer] Error during shutdown:', error);
process.exit(1);
}
});

View File

@@ -0,0 +1,244 @@
import { Room, matchMaker, type Client } from 'colyseus';
import { ExperimentRoomState, Participant } from '../schemas/ExperimentRoomState.js';
import { db } from '../db.js';
import * as schema from '../../db/schema.js';
import { eq } from 'drizzle-orm';
export class ExperimentRoom extends Room<ExperimentRoomState> {
maxClients = 1; // Will be set dynamically from database
async onCreate(options: { experimentId: string }) {
this.setState(new ExperimentRoomState());
// Get experiment details from database
const experiment = await db.query.experiment.findFirst({
where: eq(schema.experiment.id, options.experimentId)
});
if (!experiment) {
throw new Error(`Experiment ${options.experimentId} not found`);
}
if (!experiment.multiplayer) {
throw new Error(`Experiment ${options.experimentId} is not a multiplayer experiment`);
}
// Configure room
this.maxClients = experiment.maxParticipants;
this.state.experimentId = options.experimentId;
this.state.maxParticipants = experiment.maxParticipants;
this.state.createdAt = Date.now();
console.log(`[ExperimentRoom] Created room for experiment ${options.experimentId} (max: ${experiment.maxParticipants})`);
// Set up message handlers
this.onMessage('ready', (client, message) => {
this.handleParticipantReady(client, message);
});
this.onMessage('experiment_action', (client, message) => {
this.handleExperimentAction(client, message);
});
this.onMessage('experiment_state', (client, message) => {
this.handleExperimentState(client, message);
});
// Auto-dispose empty rooms after 30 minutes
this.autoDispose = false;
setTimeout(() => {
if (this.clients.length === 0) {
this.disconnect();
}
}, 30 * 60 * 1000);
}
async onJoin(client: Client, options: any) {
console.log(`[ExperimentRoom] Client ${client.sessionId} attempting to join room ${this.roomId}`);
// Check if room is full
if (this.state.currentParticipants >= this.state.maxParticipants) {
// Add to queue
this.state.queue.push(client.sessionId);
console.log(`[ExperimentRoom] Client ${client.sessionId} added to queue (position: ${this.state.queue.length})`);
// Notify client they're in queue
client.send('queued', {
position: this.state.queue.length,
message: 'You are in the queue. You will be admitted when a spot opens up.'
});
// Don't create participant yet - they're just in queue
return;
}
// Create participant
const participant = new Participant();
participant.id = client.sessionId;
participant.sessionId = client.sessionId;
participant.joinedAt = Date.now();
participant.userAgent = options.userAgent || '';
participant.ipAddress = options.ipAddress || '';
this.state.participants.set(client.sessionId, participant);
this.state.currentParticipants++;
console.log(`[ExperimentRoom] Client ${client.sessionId} joined (${this.state.currentParticipants}/${this.state.maxParticipants})`);
// Check if experiment should start
this.checkExperimentStart();
// Send current state to client
client.send('joined', {
participantId: client.sessionId,
currentParticipants: this.state.currentParticipants,
maxParticipants: this.state.maxParticipants,
status: this.state.status
});
}
async onLeave(client: Client, consented: boolean) {
console.log(`[ExperimentRoom] Client ${client.sessionId} left (consented: ${consented})`);
// Remove from participants if they were participating
if (this.state.participants.has(client.sessionId)) {
this.state.participants.delete(client.sessionId);
this.state.currentParticipants--;
// Process queue if there's space
this.processQueue();
} else {
// Remove from queue
const queueIndex = this.state.queue.indexOf(client.sessionId);
if (queueIndex !== -1) {
this.state.queue.splice(queueIndex, 1);
// Notify remaining queue members of new positions
this.notifyQueuePositions();
}
}
// If experiment is running and someone left, handle accordingly
if (this.state.status === 'running' && this.state.currentParticipants === 0) {
this.state.status = 'completed';
this.state.completedAt = Date.now();
console.log(`[ExperimentRoom] Experiment completed - all participants left`);
}
}
private async processQueue() {
if (this.state.queue.length === 0 || this.state.currentParticipants >= this.state.maxParticipants) {
return;
}
const nextSessionId = this.state.queue.shift();
if (nextSessionId) {
// Find the client in the room
const client = this.clients.find(c => c.sessionId === nextSessionId);
if (client) {
// Move from queue to participants
const participant = new Participant();
participant.id = client.sessionId;
participant.sessionId = client.sessionId;
participant.joinedAt = Date.now();
this.state.participants.set(client.sessionId, participant);
this.state.currentParticipants++;
console.log(`[ExperimentRoom] Client ${client.sessionId} moved from queue to participants`);
// Notify client they're now participating
client.send('admitted', {
participantId: client.sessionId,
currentParticipants: this.state.currentParticipants,
maxParticipants: this.state.maxParticipants,
status: this.state.status
});
// Check if experiment should start
this.checkExperimentStart();
}
// Update queue positions for remaining clients
this.notifyQueuePositions();
}
}
private notifyQueuePositions() {
this.state.queue.forEach((sessionId, index) => {
const client = this.clients.find(c => c.sessionId === sessionId);
if (client) {
client.send('queue_update', {
position: index + 1,
message: `You are #${index + 1} in the queue`
});
}
});
}
private checkExperimentStart() {
// Auto-start when room is full (this can be customized)
if (this.state.currentParticipants === this.state.maxParticipants && this.state.status === 'waiting') {
this.startExperiment();
}
}
private startExperiment() {
this.state.status = 'running';
this.state.startedAt = Date.now();
console.log(`[ExperimentRoom] Experiment started for ${this.state.experimentId}`);
// Notify all participants
this.broadcast('experiment_started', {
message: 'Experiment is now starting',
participants: this.state.currentParticipants
});
}
private handleParticipantReady(client: Client, message: any) {
const participant = this.state.participants.get(client.sessionId);
if (participant) {
participant.isReady = true;
// Check if all participants are ready
const allReady = Array.from(this.state.participants.values()).every(p => p.isReady);
if (allReady && this.state.status === 'waiting') {
this.startExperiment();
}
}
}
private handleExperimentAction(client: Client, message: any) {
// Broadcast experiment actions to all participants
this.broadcast('experiment_action', {
participantId: client.sessionId,
action: message.action,
data: message.data,
timestamp: Date.now()
}, { except: client });
}
private handleExperimentState(client: Client, message: any) {
// Update experiment state
this.state.experimentState = JSON.stringify(message.state);
// Broadcast state update to all participants
this.broadcast('experiment_state_update', {
state: message.state,
updatedBy: client.sessionId,
timestamp: Date.now()
}, { except: client });
}
async onDispose() {
console.log(`[ExperimentRoom] Room ${this.roomId} disposed`);
// Mark experiment as completed if it was running
if (this.state.status === 'running') {
this.state.status = 'completed';
this.state.completedAt = Date.now();
}
}
}

View File

@@ -0,0 +1,24 @@
import { Schema, type, MapSchema } from '@colyseus/schema';
export class Participant extends Schema {
@type('string') id: string = '';
@type('string') sessionId: string = '';
@type('number') joinedAt: number = 0;
@type('boolean') isReady: boolean = false;
@type('boolean') isConnected: boolean = true;
@type('string') userAgent?: string = '';
@type('string') ipAddress?: string = '';
}
export class ExperimentRoomState extends Schema {
@type('string') experimentId: string = '';
@type('string') status: 'waiting' | 'running' | 'completed' = 'waiting';
@type('number') maxParticipants: number = 1;
@type('number') currentParticipants: number = 0;
@type('number') createdAt: number = 0;
@type('number') startedAt?: number = 0;
@type('number') completedAt?: number = 0;
@type({ map: Participant }) participants = new MapSchema<Participant>();
@type(['string']) queue: string[] = [];
@type('string') experimentState?: string = ''; // JSON string of current experiment state
}

View File

@@ -0,0 +1,110 @@
import puppeteer, { Browser, Page } from 'puppeteer-core';
import { env } from '$env/dynamic/private';
import { EventEmitter } from 'events';
interface Session {
page: Page;
browser: Browser;
}
class SessionManager extends EventEmitter {
private sessions: Map<string, Session> = new Map();
private screenshotIntervals: Map<string, NodeJS.Timeout> = new Map();
constructor() {
super();
this.cleanup = this.cleanup.bind(this);
process.on('exit', this.cleanup);
process.on('SIGINT', this.cleanup);
process.on('SIGTERM', this.cleanup);
process.on('uncaughtException', this.cleanup);
}
async createSession(experimentId: string): Promise<void> {
if (this.sessions.has(experimentId)) {
console.log(`Session already exists for experiment ${experimentId}`);
return;
}
console.log(`Creating session for experiment ${experimentId}`);
try {
const browser = await puppeteer.connect({
browserWSEndpoint: `ws://${env.CHROME_HOST}:${env.CHROME_PORT}`
});
const page = await browser.newPage();
// Set a large viewport size to capture the whole experiment.
// This might need to be adjusted based on the experiment's design.
await page.setViewport({ width: 1920, height: 1080 });
const experimentUrl = `http://localhost:5173/public/run/${experimentId}/`;
await page.goto(experimentUrl, { waitUntil: 'networkidle0' });
this.sessions.set(experimentId, { page, browser });
// Start taking screenshots every 500ms
const interval = setInterval(async () => {
if (!this.sessions.has(experimentId)) {
clearInterval(interval);
return;
}
try {
const screenshot = await page.screenshot({ encoding: 'base64' });
this.emit(`screenshot:${experimentId}`, screenshot);
} catch (error) {
console.error(`Error taking screenshot for experiment ${experimentId}:`, error);
await this.cleanupSession(experimentId);
}
}, 500);
this.screenshotIntervals.set(experimentId, interval);
page.on('close', () => {
console.log(`Page closed for experiment ${experimentId}`);
this.cleanupSession(experimentId);
});
} catch (error) {
console.error(`Error creating session for experiment ${experimentId}:`, error);
await this.cleanupSession(experimentId);
}
}
getSession(experimentId: string): Session | undefined {
return this.sessions.get(experimentId);
}
async cleanupSession(experimentId: string): Promise<void> {
console.log(`Cleaning up session for experiment ${experimentId}`);
const interval = this.screenshotIntervals.get(experimentId);
if (interval) {
clearInterval(interval);
this.screenshotIntervals.delete(experimentId);
}
const session = this.sessions.get(experimentId);
if (session) {
try {
if (!session.page.isClosed()) {
await session.page.close();
}
} catch (error) {
console.error(`Error closing page for experiment ${experimentId}:`, error);
}
this.sessions.delete(experimentId);
}
}
async cleanup(): Promise<void> {
console.log('Cleaning up all sessions...');
const allSessions = Array.from(this.sessions.keys());
for (const experimentId of allSessions) {
await this.cleanupSession(experimentId);
}
console.log('All sessions cleaned up.');
process.exit(0);
}
}
export const sessionManager = new SessionManager();

View File

@@ -0,0 +1,32 @@
import { startMultiplayerServer } from './colyseusServer.js';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
const PORT = parseInt(process.env.MULTIPLAYER_PORT || '8080');
console.log('[Multiplayer] Starting standalone Colyseus server...');
// Start the multiplayer server
startMultiplayerServer(PORT)
.then((server) => {
console.log(`[Multiplayer] Server started successfully on port ${PORT}`);
console.log(`[Multiplayer] WebSocket endpoint: ws://localhost:${PORT}`);
console.log(`[Multiplayer] Ready to accept connections`);
})
.catch((error) => {
console.error('[Multiplayer] Failed to start server:', error);
process.exit(1);
});
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('[Multiplayer] Received SIGINT, shutting down gracefully...');
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log('[Multiplayer] Received SIGTERM, shutting down gracefully...');
process.exit(0);
});

View File

@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import * as schema from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
export async function GET({ params }) {
const { experimentId } = params;
try {
const experiment = await db.query.experiment.findFirst({
where: eq(schema.experiment.id, experimentId)
});
if (!experiment) {
throw error(404, 'Experiment not found');
}
return json({
id: experiment.id,
name: experiment.name,
description: experiment.description,
multiplayer: experiment.multiplayer,
maxParticipants: experiment.maxParticipants,
type: experiment.type,
createdAt: experiment.createdAt
});
} catch (err) {
console.error('Error fetching experiment:', err);
throw error(500, 'Internal server error');
}
}

View File

@@ -0,0 +1,19 @@
<!-- This layout bypasses the main app layout for multiplayer experiments -->
<slot />
<style>
/* Ensure multiplayer experiments are completely full-screen */
:global(html), :global(body) {
margin: 0;
padding: 0;
width: 100%;
min-height: 100vh;
overflow: auto;
background: white;
}
/* Reset any inherited styles */
:global(*) {
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,652 @@
import { db } from '$lib/server/db/index.js';
import * as schema from '$lib/server/db/schema.js';
import s3 from '$lib/server/s3.js';
import { GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
import { eq } from 'drizzle-orm';
import { error } from '@sveltejs/kit';
import mime from 'mime-types';
import { S3_BUCKET } from '$env/static/private';
import { dev } from '$app/environment';
import { promises as fs } from 'fs';
const EXPERIMENT_SESSION_COOKIE_PREFIX = 'experiment-session-';
// Rules for proxying vendor files to a CDN using regex to extract versions
const vendorFileRules = [
{
regex: /^lib\/vendors\/jquery-([\d.]+)\.min\.js$/,
url: (version: string) => `https://code.jquery.com/jquery-${version}.min.js`
},
{
regex: /^lib\/vendors\/surveyjs\.jquery-([\d.]+)\.min\.js$/,
url: (version: string) => `https://unpkg.com/survey-jquery@${version}/survey.jquery.min.js`
},
{
regex: /^lib\/vendors\/surveyjs\.defaultV2-([\d.]+)-OST\.min\.css$/,
url: (version: string) => `https://unpkg.com/survey-core@${version}/defaultV2.min.css`
}
];
export async function GET({ params, cookies, getClientAddress, request }) {
const { experimentId } = params;
// Check if the experiment exists
const experiment = await db.query.experiment.findFirst({
where: eq(schema.experiment.id, experimentId)
});
if (!experiment) {
throw error(404, 'Experiment not found');
}
// For multiplayer, we always serve index.html with WebSocket integration
const path = 'index.html';
const cookieName = `${EXPERIMENT_SESSION_COOKIE_PREFIX}${experimentId}`;
let experimentSessionId = cookies.get(cookieName);
if (!experimentSessionId) {
// First request for this experiment. Create a new participant session.
experimentSessionId = randomUUID();
const now = new Date();
await db.insert(schema.experimentSession).values({
id: experimentSessionId,
experimentId,
createdAt: now,
updatedAt: now,
status: 'in progress',
ipAddress: getClientAddress(),
userAgent: request.headers.get('user-agent') ?? undefined
});
cookies.set(cookieName, experimentSessionId, {
path: `/public/multiplayer/run/${experimentId}`,
httpOnly: true,
secure: !dev,
maxAge: 60 * 60 * 24 * 365 // 1 year
});
} else {
// subsequent requests, check if cookie is valid
const [session] = await db
.select()
.from(schema.experimentSession)
.where(eq(schema.experimentSession.id, experimentSessionId));
if (!session) {
// invalid cookie, create new session
const newExperimentSessionId = randomUUID();
const now = new Date();
await db.insert(schema.experimentSession).values({
id: newExperimentSessionId,
experimentId,
createdAt: now,
updatedAt: now,
status: 'in progress',
ipAddress: getClientAddress(),
userAgent: request.headers.get('user-agent') ?? undefined
});
cookies.set(cookieName, newExperimentSessionId, {
path: `/public/multiplayer/run/${experimentId}`,
httpOnly: true,
secure: !dev,
maxAge: 60 * 60 * 24 * 365 // 1 year
});
}
}
// Files are stored in the user's experiment_files directory
const createdBy = experiment.createdBy;
const s3Prefix = `${createdBy}/${experimentId}/experiment_files/`;
const key = `${s3Prefix}${path}`;
try {
const command = new GetObjectCommand({
Bucket: S3_BUCKET,
Key: key
});
const s3Response = await s3.send(command);
if (!s3Response.Body) {
throw error(404, 'Not found');
}
const stream = s3Response.Body;
const body = await stream.transformToString();
// Inject multiplayer WebSocket integration into the HTML
const multiplayerScript = `
<script src="https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js"></script>
<script>
// Multiplayer Colyseus Integration
let client = null;
let room = null;
let isConnected = false;
let overlayVisible = true;
let sessionStartTime = new Date();
let isLoading = true;
let participantId = null;
let roomStatus = 'connecting';
let queuePosition = 0;
let currentParticipants = 0;
let maxParticipants = 1;
// Connect to Colyseus server
async function connectMultiplayer() {
try {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const host = location.hostname;
const port = '8080'; // Colyseus server port
client = new Colyseus.Client(\`\${protocol}://\${host}:\${port}\`);
// Join or create room for this experiment
room = await client.joinOrCreate('experiment_room', {
experimentId: '${experimentId}',
userAgent: navigator.userAgent,
ipAddress: null // Will be set by server
});
isConnected = true;
roomStatus = 'connected';
updateOverlay();
console.log('Connected to Colyseus room:', room.roomId);
// Handle room state changes
room.onStateChange((state) => {
console.log('Room state updated:', state);
handleStateUpdate(state);
});
// Handle messages from server
room.onMessage('queued', (message) => {
handleQueued(message);
});
room.onMessage('admitted', (message) => {
handleAdmitted(message);
});
room.onMessage('joined', (message) => {
handleJoined(message);
});
room.onMessage('queue_update', (message) => {
handleQueueUpdate(message);
});
room.onMessage('experiment_started', (message) => {
handleExperimentStarted(message);
});
room.onMessage('experiment_action', (message) => {
handleExperimentAction(message);
});
room.onMessage('experiment_state_update', (message) => {
handleExperimentStateUpdate(message);
});
room.onError((code, message) => {
console.error('Room error:', code, message);
isConnected = false;
roomStatus = 'error';
updateOverlay();
});
room.onLeave((code) => {
console.log('Left room with code:', code);
isConnected = false;
roomStatus = 'disconnected';
updateOverlay();
});
} catch (error) {
console.error('Failed to connect to multiplayer server:', error);
isConnected = false;
roomStatus = 'error';
updateOverlay();
}
}
function handleStateUpdate(state) {
currentParticipants = state.currentParticipants;
maxParticipants = state.maxParticipants;
// Update participant info
if (state.participants && participantId) {
const participant = state.participants.get(participantId);
if (participant) {
console.log('Participant updated:', participant);
}
}
updateOverlay();
}
function handleQueued(message) {
roomStatus = 'queued';
queuePosition = message.position;
console.log('Added to queue:', message);
updateOverlay();
}
function handleAdmitted(message) {
roomStatus = 'admitted';
participantId = message.participantId;
currentParticipants = message.currentParticipants;
maxParticipants = message.maxParticipants;
console.log('Admitted to experiment:', message);
if (isLoading) {
isLoading = false;
hideLoadingScreen();
}
updateOverlay();
}
function handleJoined(message) {
roomStatus = 'joined';
participantId = message.participantId;
currentParticipants = message.currentParticipants;
maxParticipants = message.maxParticipants;
console.log('Joined experiment:', message);
if (isLoading) {
isLoading = false;
hideLoadingScreen();
}
updateOverlay();
}
function handleQueueUpdate(message) {
queuePosition = message.position;
console.log('Queue position updated:', message);
updateOverlay();
}
function handleExperimentStarted(message) {
roomStatus = 'running';
console.log('Experiment started:', message);
updateOverlay();
}
function handleExperimentAction(message) {
// Handle actions from other participants
console.log('Experiment action from participant:', message);
// This would be where you'd handle specific experiment interactions
}
function handleExperimentStateUpdate(message) {
// Handle experiment state updates
console.log('Experiment state updated:', message);
// This would be where you'd sync experiment state across participants
}
function hideLoadingScreen() {
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) {
loadingScreen.style.display = 'none';
}
}
function updateOverlay() {
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
const participantCount = document.getElementById('participant-count');
const queueInfo = document.getElementById('queue-info');
if (statusDot) {
statusDot.className = 'status-dot ' + (isConnected ? 'connected' : 'disconnected');
}
if (statusText) {
let statusMessage = 'Disconnected';
if (isConnected) {
switch (roomStatus) {
case 'connected':
statusMessage = 'Connected';
break;
case 'queued':
statusMessage = \`Queue #\${queuePosition}\`;
break;
case 'admitted':
case 'joined':
statusMessage = 'In Session';
break;
case 'running':
statusMessage = 'Running';
break;
default:
statusMessage = 'Connected';
}
}
statusText.textContent = statusMessage;
}
if (participantCount) {
participantCount.textContent = \`\${currentParticipants}/\${maxParticipants}\`;
}
if (queueInfo) {
if (roomStatus === 'queued') {
queueInfo.textContent = \`Position \${queuePosition} in queue\`;
queueInfo.style.display = 'block';
} else {
queueInfo.style.display = 'none';
}
}
}
function toggleOverlay() {
const overlay = document.getElementById('multiplayer-overlay');
const minimized = document.getElementById('minimized-overlay');
if (overlay && minimized) {
overlayVisible = !overlayVisible;
overlay.style.display = overlayVisible ? 'block' : 'none';
minimized.style.display = overlayVisible ? 'none' : 'block';
}
}
// Send experiment action to other participants
function sendExperimentAction(action, data) {
if (room && roomStatus === 'running') {
room.send('experiment_action', { action, data });
}
}
// Send experiment state update
function sendExperimentState(state) {
if (room && roomStatus === 'running') {
room.send('experiment_state', { state });
}
}
// Mark participant as ready
function markReady() {
if (room && participantId) {
room.send('ready', { participantId });
}
}
// Handle user interactions
function handleInteraction(event) {
if (event instanceof KeyboardEvent && event.key === 'Tab') {
event.preventDefault();
toggleOverlay();
return;
}
// For now, just log interactions - experiment-specific handling would go here
if (roomStatus === 'running') {
console.log('User interaction:', event.type, event);
}
}
// Initialize multiplayer when DOM is ready
function initMultiplayer() {
// Add event listeners
document.addEventListener('keydown', handleInteraction, true);
// Connect to Colyseus
connectMultiplayer();
}
// Start multiplayer when page loads
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMultiplayer);
} else {
initMultiplayer();
}
// Make functions available globally for experiment use
window.multiplayerAPI = {
sendAction: sendExperimentAction,
sendState: sendExperimentState,
markReady: markReady,
getStatus: () => ({ roomStatus, participantId, currentParticipants, maxParticipants })
};
</script>
`;
const multiplayerCSS = `
<style>
/* Multiplayer overlay styles */
#multiplayer-overlay {
position: fixed;
top: 16px;
right: 16px;
z-index: 9999;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 12px 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
max-width: 250px;
}
#minimized-overlay {
position: fixed;
top: 16px;
right: 16px;
z-index: 9999;
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 8px 12px;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: pointer;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
display: none;
}
.overlay-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.connected {
background: #10b981;
}
.status-dot.disconnected {
background: #ef4444;
}
.close-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 16px;
padding: 0;
margin-left: 12px;
}
.close-btn:hover {
color: white;
}
.overlay-info {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
line-height: 1.4;
}
.overlay-separator {
margin: 8px 0;
border-top: 1px solid rgba(255, 255, 255, 0.2);
padding-top: 8px;
}
#loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #f0f9ff 0%, #e0e7ff 100%);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.loading-content {
text-align: center;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 3px solid #e2e8f0;
border-top: 3px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
color: #374151;
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
}
.loading-subtext {
color: #6b7280;
font-size: 14px;
margin-bottom: 16px;
}
.loading-hint {
color: #9ca3af;
font-size: 12px;
}
/* Ensure overlay elements don't interfere with experiment */
#multiplayer-overlay,
#minimized-overlay {
pointer-events: auto;
}
#multiplayer-overlay *,
#minimized-overlay * {
pointer-events: auto;
}
/* Full screen styles */
body {
margin: 0;
padding: 0;
width: 100%;
min-height: 100vh;
background: white;
}
html {
margin: 0;
padding: 0;
width: 100%;
min-height: 100vh;
}
</style>
`;
const multiplayerOverlay = `
<!-- Loading screen -->
<div id="loading-screen">
<div class="loading-content">
<div class="loading-spinner"></div>
<div class="loading-text">Loading experiment...</div>
<div class="loading-subtext">Connecting to multiplayer session</div>
<div class="loading-hint">Your experiment will open in full screen</div>
</div>
</div>
<!-- Multiplayer overlay -->
<div id="multiplayer-overlay">
<div class="overlay-header">
<div class="status-indicator">
<div id="status-dot" class="status-dot disconnected"></div>
<span id="status-text">Disconnected</span>
</div>
<button class="close-btn" onclick="toggleOverlay()">×</button>
</div>
<div class="overlay-info">
<div>🎮 Multiplayer Session</div>
<div>👥 Participants: <span id="participant-count">0/1</span></div>
<div>🕐 Started: <span id="session-time"></span></div>
<div>📋 ID: ${experimentId.slice(0, 8)}...</div>
<div id="queue-info" style="display: none; color: #fbbf24; margin-top: 4px;">
Position in queue
</div>
<div class="overlay-separator">
<div style="color: rgba(255, 255, 255, 0.6);">Press Tab to toggle this overlay</div>
</div>
</div>
</div>
<!-- Minimized overlay -->
<div id="minimized-overlay" onclick="toggleOverlay()">
<div style="display: flex; align-items: center; gap: 8px;">
<div id="minimized-status-dot" class="status-dot disconnected"></div>
<span>MP</span>
</div>
</div>
<script>
// Update session time
document.getElementById('session-time').textContent = new Date().toLocaleTimeString();
</script>
`;
// Inject multiplayer functionality into the HTML
const injectedBody = body
.replace(/<head[^>]*>/, `$&<base href="/public/multiplayer/run/${experimentId}/">${multiplayerCSS}`)
.replace(/<\/body>/, `${multiplayerOverlay}${multiplayerScript}</body>`);
return new Response(injectedBody, {
headers: {
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(injectedBody, 'utf8').toString()
}
});
} catch (e: any) {
if (e.name === 'NoSuchKey') {
throw error(404, 'Not found');
}
throw error(500, 'Internal server error');
}
}

229
test-multiplayer.html Normal file
View File

@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multiplayer Test</title>
<script src="https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js"></script>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.status {
padding: 10px;
margin: 10px 0;
border-radius: 5px;
}
.connected {
background-color: #d4edda;
color: #155724;
}
.disconnected {
background-color: #f8d7da;
color: #721c24;
}
.queued {
background-color: #fff3cd;
color: #856404;
}
.log {
background-color: #f8f9fa;
padding: 10px;
margin: 10px 0;
border-radius: 5px;
font-family: monospace;
white-space: pre-wrap;
max-height: 400px;
overflow-y: auto;
}
button {
padding: 10px 20px;
margin: 5px;
border: none;
border-radius: 5px;
cursor: pointer;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
</style>
</head>
<body>
<h1>Multiplayer Test</h1>
<div id="status" class="status disconnected">
Status: Disconnected
</div>
<div>
<button id="connectBtn" class="btn-primary" onclick="connectToRoom()">Connect to Room</button>
<button id="disconnectBtn" class="btn-secondary" onclick="disconnectFromRoom()" disabled>Disconnect</button>
<button id="readyBtn" class="btn-primary" onclick="markReady()" disabled>Mark Ready</button>
</div>
<div>
<h3>Room Info</h3>
<p>Room ID: <span id="roomId">-</span></p>
<p>Participants: <span id="participants">0/1</span></p>
<p>Queue Position: <span id="queuePosition">-</span></p>
<p>Status: <span id="roomStatus">-</span></p>
</div>
<div>
<h3>Log</h3>
<div id="log" class="log"></div>
</div>
<script>
let client = null;
let room = null;
let isConnected = false;
const statusDiv = document.getElementById('status');
const connectBtn = document.getElementById('connectBtn');
const disconnectBtn = document.getElementById('disconnectBtn');
const readyBtn = document.getElementById('readyBtn');
const roomIdSpan = document.getElementById('roomId');
const participantsSpan = document.getElementById('participants');
const queuePositionSpan = document.getElementById('queuePosition');
const roomStatusSpan = document.getElementById('roomStatus');
const logDiv = document.getElementById('log');
function log(message) {
const timestamp = new Date().toLocaleTimeString();
const logMessage = `[${timestamp}] ${message}`;
logDiv.textContent += logMessage + '\n';
logDiv.scrollTop = logDiv.scrollHeight;
console.log(logMessage);
}
function updateStatus(status, className) {
statusDiv.textContent = `Status: ${status}`;
statusDiv.className = `status ${className}`;
}
function updateUI() {
connectBtn.disabled = isConnected;
disconnectBtn.disabled = !isConnected;
readyBtn.disabled = !isConnected;
}
async function connectToRoom() {
try {
log('Connecting to Colyseus server...');
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const host = location.hostname;
const port = '8080';
client = new Colyseus.Client(`${protocol}://${host}:${port}`);
log('Joining experiment room...');
room = await client.joinOrCreate('experiment_room', {
experimentId: 'test-experiment-123',
userAgent: navigator.userAgent,
ipAddress: null
});
isConnected = true;
updateStatus('Connected', 'connected');
updateUI();
roomIdSpan.textContent = room.roomId;
log(`Connected to room: ${room.roomId}`);
// Handle room state changes
room.onStateChange((state) => {
log(`Room state updated: ${JSON.stringify(state, null, 2)}`);
participantsSpan.textContent = `${state.currentParticipants}/${state.maxParticipants}`;
roomStatusSpan.textContent = state.status;
});
// Handle messages
room.onMessage('queued', (message) => {
log(`Queued: ${JSON.stringify(message)}`);
updateStatus(`Queued (Position: ${message.position})`, 'queued');
queuePositionSpan.textContent = message.position;
});
room.onMessage('admitted', (message) => {
log(`Admitted: ${JSON.stringify(message)}`);
updateStatus('Admitted', 'connected');
queuePositionSpan.textContent = '-';
});
room.onMessage('joined', (message) => {
log(`Joined: ${JSON.stringify(message)}`);
updateStatus('Joined', 'connected');
queuePositionSpan.textContent = '-';
});
room.onMessage('queue_update', (message) => {
log(`Queue update: ${JSON.stringify(message)}`);
queuePositionSpan.textContent = message.position;
});
room.onMessage('experiment_started', (message) => {
log(`Experiment started: ${JSON.stringify(message)}`);
updateStatus('Experiment Running', 'connected');
});
room.onError((code, message) => {
log(`Room error: ${code} - ${message}`);
updateStatus('Error', 'disconnected');
});
room.onLeave((code) => {
log(`Left room with code: ${code}`);
isConnected = false;
updateStatus('Disconnected', 'disconnected');
updateUI();
});
} catch (error) {
log(`Connection error: ${error.message}`);
updateStatus('Connection Failed', 'disconnected');
isConnected = false;
updateUI();
}
}
function disconnectFromRoom() {
if (room) {
log('Disconnecting from room...');
room.leave();
room = null;
client = null;
isConnected = false;
updateStatus('Disconnected', 'disconnected');
updateUI();
// Reset UI
roomIdSpan.textContent = '-';
participantsSpan.textContent = '0/1';
queuePositionSpan.textContent = '-';
roomStatusSpan.textContent = '-';
}
}
function markReady() {
if (room) {
log('Marking as ready...');
room.send('ready', { participantId: room.sessionId });
}
}
// Initial UI update
updateUI();
log('Multiplayer test page loaded');
</script>
</body>
</html>

View File

@@ -9,7 +9,9 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files