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

@@ -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');
}
}