From 83bd893d93e481fc743f183c809419f398ceb87c Mon Sep 17 00:00:00 2001 From: KeshavAnandCode Date: Wed, 25 Mar 2026 21:07:18 +0000 Subject: [PATCH] Update buzzer app to serve static files and handle WebSocket connections Integrates static file serving for HTML, CSS, and JS, and sets up a WebSocket server for real-time communication. Replit-Commit-Author: Agent Replit-Commit-Session-Id: f3ac8eb3-f610-4678-ab6e-ebf900098be4 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 182c9671-a459-4b94-a7eb-1c7a0cefe768 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/d4b7863b-f7b2-425c-a9b5-ad7bd1885e9d/f3ac8eb3-f610-4678-ab6e-ebf900098be4/N1qS6qo Replit-Helium-Checkpoint-Created: true --- server/buzzer-rooms.ts | 99 +++++ server/buzzer-ws.ts | 214 +++++++++ server/routes.ts | 40 +- src/public/index.html | 95 ++-- src/public/script.js | 988 +++++++++++++++++++++++----------------- src/public/styles.css | 989 +++++++++++++++++++++++++++++------------ 6 files changed, 1680 insertions(+), 745 deletions(-) create mode 100644 server/buzzer-rooms.ts create mode 100644 server/buzzer-ws.ts diff --git a/server/buzzer-rooms.ts b/server/buzzer-rooms.ts new file mode 100644 index 0000000..2ff9f33 --- /dev/null +++ b/server/buzzer-rooms.ts @@ -0,0 +1,99 @@ +import type { WebSocket } from "ws"; + +export interface RoomSettings { + mode: "individual" | "teams"; + numTeams: number; + teamNames: string[]; + playerPickTeam: boolean; + showBuzzOrder: boolean; + buzzerLockout: boolean; + timerSeconds: number; +} + +export interface Player { + id: string; + name: string; + teamIndex: number | null; + ws: WebSocket | null; + isConnected: boolean; +} + +export interface BuzzerState { + roundOpen: boolean; + buzzOrder: string[]; + buzzTimes: Map; +} + +export interface Room { + id: string; + moderatorSecret: string; + modWs: WebSocket | null; + settings: RoomSettings; + players: Map; + buzzerState: BuzzerState; + locked: boolean; + teamLocked: boolean; +} + +export const rooms = new Map(); +export const wsToPlayer = new Map(); + +const GREEK = ["Alpha","Beta","Gamma","Delta","Epsilon","Zeta","Eta","Theta","Iota","Kappa","Lambda","Mu","Nu","Xi","Omicron","Pi","Rho","Sigma","Tau","Upsilon","Phi","Chi","Psi","Omega"]; +export function greekName(i: number): string { + const cycle = Math.floor(i / GREEK.length); + const name = GREEK[i % GREEK.length]; + return cycle === 0 ? name : `${name} ${toRoman(cycle + 1)}`; +} +function toRoman(n: number): string { + const vals = [1000,900,500,400,100,90,50,40,10,9,5,4,1]; + const syms = ["M","CM","D","CD","C","XC","L","XL","X","IX","V","IV","I"]; + let out = ""; + for (let i = 0; i < vals.length; i++) while (n >= vals[i]) { out += syms[i]; n -= vals[i]; } + return out; +} + +export function genId(len = 8): string { + const c = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + let s = ""; + for (let i = 0; i < len; i++) s += c[Math.floor(Math.random() * c.length)]; + return s; +} + +export function sanitize(v: unknown, max = 32): string { + return String(v ?? "").trim().replace(/[<>"'`]/g, "").slice(0, max); +} + +export function freshBuzzer(): BuzzerState { + return { roundOpen: false, buzzOrder: [], buzzTimes: new Map() }; +} + +export function publicRoom(room: Room) { + return { + id: room.id, + settings: room.settings, + locked: room.locked, + teamLocked: room.teamLocked, + modOnline: room.modWs !== null, + buzzerState: { + roundOpen: room.buzzerState.roundOpen, + buzzOrder: room.buzzerState.buzzOrder, + }, + players: Array.from(room.players.values()).map(p => ({ + id: p.id, + name: p.name, + teamIndex: p.teamIndex, + isConnected: p.isConnected, + })), + }; +} + +export function broadcast(room: Room, msg: object, exclude?: WebSocket) { + const d = JSON.stringify(msg); + for (const p of room.players.values()) + if (p.ws && p.isConnected && p.ws !== exclude) try { p.ws.send(d); } catch {} + if (room.modWs && room.modWs !== exclude) try { room.modWs.send(d); } catch {} +} + +export function toMod(room: Room, msg: object) { + if (room.modWs) try { room.modWs.send(JSON.stringify(msg)); } catch {} +} diff --git a/server/buzzer-ws.ts b/server/buzzer-ws.ts new file mode 100644 index 0000000..0586219 --- /dev/null +++ b/server/buzzer-ws.ts @@ -0,0 +1,214 @@ +import type { WebSocket } from "ws"; +import { + rooms, wsToPlayer, + Room, Player, + genId, greekName, sanitize, freshBuzzer, publicRoom, broadcast, toMod, +} from "./buzzer-rooms"; + +const tx = (ws: WebSocket, msg: object) => { try { ws.send(JSON.stringify(msg)); } catch {} }; +const er = (ws: WebSocket, m: string) => tx(ws, { type: "error", message: m }); +const modOf = (ws: WebSocket): Room | null => { for (const r of rooms.values()) if (r.modWs === ws) return r; return null; }; + +export function handleMessage(ws: WebSocket, raw: string) { + let msg: any; + try { msg = JSON.parse(raw); } catch { return; } + const type: string = msg?.type ?? ""; + + if (type === "create_room") { + const s = msg.settings ?? {}; + const numTeams = Math.max(2, Math.min(64, (s.numTeams ?? 2) | 0)); + const teamNames: string[] = []; + for (let i = 0; i < numTeams; i++) + teamNames.push(s.teamNames?.[i] ? sanitize(s.teamNames[i], 32) : greekName(i)); + + const room: Room = { + id: genId(6), + moderatorSecret: genId(24), + modWs: ws, + settings: { + mode: s.mode === "teams" ? "teams" : "individual", + numTeams, + teamNames, + playerPickTeam: s.playerPickTeam === true, + showBuzzOrder: s.showBuzzOrder !== false, + buzzerLockout: s.buzzerLockout !== false, + timerSeconds: Math.min(600, Math.max(0, (s.timerSeconds ?? 0) | 0)), + }, + players: new Map(), + buzzerState: freshBuzzer(), + locked: false, + teamLocked: false, + }; + rooms.set(room.id, room); + tx(ws, { type: "room_created", roomId: room.id, modSecret: room.moderatorSecret, room: publicRoom(room) }); + return; + } + + if (type === "mod_rejoin") { + const room = rooms.get(sanitize(msg.roomId, 10)); + if (!room || room.moderatorSecret !== sanitize(msg.modSecret, 32)) { + er(ws, "Invalid credentials"); return; + } + room.modWs = ws; + tx(ws, { type: "mod_joined", room: publicRoom(room) }); + broadcast(room, { type: "room_update", room: publicRoom(room) }, ws); + return; + } + + if (type === "join_room") { + if (msg.modSecret) { er(ws, "Unauthorized"); return; } + const room = rooms.get(sanitize(msg.roomId ?? "", 10)); + if (!room) { er(ws, "Room not found"); return; } + if (room.locked) { er(ws, "Room is locked"); return; } + + const name = sanitize(msg.playerName ?? "Player", 24) || "Player"; + const existingId = sanitize(msg.playerId ?? "", 12); + let player: Player | undefined; + + if (existingId && room.players.has(existingId)) { + player = room.players.get(existingId)!; + player.ws = ws; player.isConnected = true; player.name = name; + } else { + player = { id: genId(), name, teamIndex: null, ws, isConnected: true }; + room.players.set(player.id, player); + } + + wsToPlayer.set(ws, { roomId: room.id, playerId: player.id }); + tx(ws, { type: "joined", playerId: player.id, room: publicRoom(room) }); + broadcast(room, { type: "room_update", room: publicRoom(room) }, ws); + return; + } + + if (type === "pick_team") { + const ctx = wsToPlayer.get(ws); + if (!ctx) return; + const room = rooms.get(ctx.roomId); + if (!room) return; + if (room.teamLocked || !room.settings.playerPickTeam || room.settings.mode !== "teams") { + er(ws, "Team selection not allowed"); return; + } + const p = room.players.get(ctx.playerId); + if (!p) return; + const ti = typeof msg.teamIndex === "number" ? msg.teamIndex : null; + p.teamIndex = ti !== null ? Math.max(0, Math.min(room.settings.numTeams - 1, ti | 0)) : null; + broadcast(room, { type: "room_update", room: publicRoom(room) }); + return; + } + + if (type === "buzz") { + const ctx = wsToPlayer.get(ws); + if (!ctx) return; + const room = rooms.get(ctx.roomId); + if (!room) return; + const bz = room.buzzerState; + + if (!bz.roundOpen) { tx(ws, { type: "buzz_rejected", reason: "Round not open" }); return; } + if (bz.buzzOrder.includes(ctx.playerId)) { tx(ws, { type: "buzz_rejected", reason: "Already buzzed" }); return; } + if (room.settings.buzzerLockout && bz.buzzOrder.length > 0) { tx(ws, { type: "buzz_rejected", reason: "Locked out" }); return; } + + bz.buzzOrder.push(ctx.playerId); + bz.buzzTimes.set(ctx.playerId, Date.now()); + + const p = room.players.get(ctx.playerId)!; + const pubOrder = room.settings.showBuzzOrder ? bz.buzzOrder : [bz.buzzOrder[0]]; + + broadcast(room, { type: "buzz_event", playerId: ctx.playerId, playerName: p.name, teamIndex: p.teamIndex, buzzOrder: pubOrder, room: publicRoom(room) }); + toMod(room, { type: "buzz_event", playerId: ctx.playerId, playerName: p.name, teamIndex: p.teamIndex, buzzOrder: bz.buzzOrder, buzzTimes: Object.fromEntries(bz.buzzTimes), room: publicRoom(room) }); + return; + } + + const room = modOf(ws); + if (!room) { er(ws, "Not authorized"); return; } + + switch (type) { + case "open_round": + room.buzzerState = freshBuzzer(); + room.buzzerState.roundOpen = true; + broadcast(room, { type: "round_open", room: publicRoom(room) }); + break; + case "close_round": + room.buzzerState.roundOpen = false; + broadcast(room, { type: "round_closed", room: publicRoom(room) }); + break; + case "reset_buzzer": + room.buzzerState = freshBuzzer(); + broadcast(room, { type: "buzzer_reset", room: publicRoom(room) }); + break; + case "update_settings": { + const s = msg.settings ?? {}; + const st = room.settings; + if (s.mode === "individual" || s.mode === "teams") st.mode = s.mode; + if (typeof s.numTeams === "number") { + const newN = Math.max(2, Math.min(64, s.numTeams | 0)); + while (st.teamNames.length < newN) st.teamNames.push(greekName(st.teamNames.length)); + st.numTeams = newN; + } + if (Array.isArray(s.teamNames)) st.teamNames = s.teamNames.slice(0, st.numTeams).map((n: unknown) => sanitize(n, 32)); + if (typeof s.playerPickTeam === "boolean") st.playerPickTeam = s.playerPickTeam; + if (typeof s.showBuzzOrder === "boolean") st.showBuzzOrder = s.showBuzzOrder; + if (typeof s.buzzerLockout === "boolean") st.buzzerLockout = s.buzzerLockout; + if (typeof s.timerSeconds === "number") st.timerSeconds = Math.min(600, Math.max(0, s.timerSeconds | 0)); + broadcast(room, { type: "room_update", room: publicRoom(room) }); + tx(ws, { type: "settings_updated", room: publicRoom(room) }); + break; + } + case "assign_team": { + const p = room.players.get(sanitize(msg.playerId, 12)); + if (p) { + p.teamIndex = typeof msg.teamIndex === "number" ? Math.max(0, Math.min(room.settings.numTeams - 1, msg.teamIndex | 0)) : null; + broadcast(room, { type: "room_update", room: publicRoom(room) }); + } + break; + } + case "kick_player": { + const p = room.players.get(sanitize(msg.playerId, 12)); + if (p) { + if (p.ws) { + try { p.ws.send(JSON.stringify({ type: "kicked" })); } catch {} + wsToPlayer.delete(p.ws); + } + room.players.delete(p.id); + broadcast(room, { type: "room_update", room: publicRoom(room) }); + } + break; + } + case "lock_room": + room.locked = msg.locked === true; + broadcast(room, { type: "room_update", room: publicRoom(room) }); + break; + case "lock_teams": + room.teamLocked = msg.locked === true; + broadcast(room, { type: "room_update", room: publicRoom(room) }); + break; + case "reset_teams": + for (const p of room.players.values()) p.teamIndex = null; + broadcast(room, { type: "room_update", room: publicRoom(room) }); + break; + case "end_room": + broadcast(room, { type: "room_ended" }); + for (const p of room.players.values()) if (p.ws) wsToPlayer.delete(p.ws); + room.modWs = null; + rooms.delete(room.id); + break; + } +} + +export function handleClose(ws: WebSocket) { + for (const room of rooms.values()) { + if (room.modWs === ws) { + room.modWs = null; + broadcast(room, { type: "mod_disconnected" }); + return; + } + } + const ctx = wsToPlayer.get(ws); + if (ctx) { + wsToPlayer.delete(ws); + const room = rooms.get(ctx.roomId); + if (room) { + const p = room.players.get(ctx.playerId); + if (p) { p.isConnected = false; p.ws = null; } + broadcast(room, { type: "room_update", room: publicRoom(room) }); + } + } +} diff --git a/server/routes.ts b/server/routes.ts index ae68e01..edeb9ae 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -1,16 +1,46 @@ import type { Express } from "express"; import { createServer, type Server } from "http"; -import { storage } from "./storage"; +import { WebSocketServer } from "ws"; +import fs from "fs"; +import path from "path"; +import { handleMessage, handleClose } from "./buzzer-ws"; export async function registerRoutes( httpServer: Server, app: Express ): Promise { - // put application routes here - // prefix all routes with /api + // Serve buzzer static files + const publicDir = path.resolve(process.cwd(), "src/public"); - // use storage to perform CRUD operations on the storage interface - // e.g. storage.insertUser(user) or storage.getUserByUsername(username) + app.get("/", (_req, res) => { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.send(fs.readFileSync(path.join(publicDir, "index.html"), "utf-8")); + }); + + app.get("/styles.css", (_req, res) => { + res.setHeader("Content-Type", "text/css"); + res.send(fs.readFileSync(path.join(publicDir, "styles.css"), "utf-8")); + }); + + app.get("/script.js", (_req, res) => { + res.setHeader("Content-Type", "text/javascript"); + res.send(fs.readFileSync(path.join(publicDir, "script.js"), "utf-8")); + }); + + // WebSocket server for buzzer + const wss = new WebSocketServer({ server: httpServer, path: "/ws" }); + + wss.on("connection", (ws) => { + ws.on("message", (data) => { + if (typeof data === "string") { + handleMessage(ws, data); + } else { + handleMessage(ws, data.toString()); + } + }); + ws.on("close", () => handleClose(ws)); + ws.on("error", () => { try { ws.close(); } catch {} }); + }); return httpServer; } diff --git a/src/public/index.html b/src/public/index.html index 8af942e..8fb8707 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -7,8 +7,7 @@ Buzzer Platform - + @@ -17,9 +16,9 @@
-
+
-
// QUIZ CONTROL
+
// QUIZ CONTROL PLATFORM
@@ -33,21 +32,28 @@
+

BUZZ

REAL-TIME QUIZ BUZZER SYSTEM

+
+
+ WEBSOCKET POWERED +
+

// HOST A SESSION

Create a room, configure teams and rules, then control the buzzer live.

+

// JOIN A SESSION

Enter a room code and your name to join an existing session.

-
@@ -65,7 +71,7 @@
-
+
// NEW ROOM SETUP
Configure before creating — all settings are adjustable in-game too.
@@ -81,28 +87,26 @@
-
+
-
- + - 2 – 64 + 2 – 64
-
- +
+
+ style="display:flex;flex-direction:column;gap:9px;max-height:240px;overflow-y:auto;padding-right:4px;">
-
+
PLAYERS PICK OWN TEAM
If off, moderator assigns teams manually
- +
@@ -121,8 +125,7 @@
SHOW FULL BUZZ ORDER TO PLAYERS
If off, players only see if they were first
- +
@@ -133,18 +136,17 @@
ENABLE COUNTDOWN TIMER
Auto-closes round when it hits zero
- +
- -
+
@@ -155,11 +157,11 @@
-
ROOM
+
ROOM CODE
──────
-
SHARE WITH PLAYERS
-
- +
SHARE WITH PLAYERS
+
+
@@ -171,7 +173,7 @@
0:30
- +
SET @@ -184,7 +186,7 @@
-
BUZZER
+
BUZZER CONTROLS
@@ -194,7 +196,7 @@
-
CONTROLS
+
ROOM CONTROLS
LOCK ROOM
-
- +
+
@@ -238,7 +239,7 @@
+
WAITING FOR ROUND
@@ -351,8 +357,8 @@