Add Akkam Bakkam classroom game
Some checks failed
CI / validate (push) Has been cancelled

This commit is contained in:
Neeldhara Misra 2026-07-20 17:23:12 +05:30
parent 52615a5086
commit e6fa86519c
16 changed files with 3277 additions and 6 deletions

452
server/dice-sync.mjs Normal file
View file

@ -0,0 +1,452 @@
import { randomInt, randomUUID } from "node:crypto";
import { WebSocket, WebSocketServer } from "ws";
const ROOM_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const ROOM_TTL_MS = 12 * 60 * 60 * 1000;
const MAX_ROOMS = 200;
const MAX_ROLLS = 120;
const MIN_ROLL_INTERVAL_MS = 350;
const DEFAULT_LEVEL_SETTINGS = Object.freeze({
gridSize: 5,
undoLimit: 3,
rerollLimit: 3,
});
const NAME_ADJECTIVES = [
"Bouncy",
"Brave",
"Curious",
"Dapper",
"Dizzy",
"Gentle",
"Jolly",
"Merry",
"Mighty",
"Nimble",
"Peppy",
"Quiet",
"Silly",
"Sunny",
"Wiggly",
"Wobbly",
];
const NAME_NOUNS = [
"Badger",
"Biscuit",
"Crumpet",
"Doodle",
"Kettle",
"Mango",
"Marble",
"Otter",
"Pancake",
"Penguin",
"Pickle",
"Puffin",
"Rocket",
"Teacup",
"Turnip",
"Wombat",
];
const makeRoomId = () =>
Array.from(
{ length: 6 },
() => ROOM_ALPHABET[randomInt(ROOM_ALPHABET.length)],
).join("");
const parseLevelSettings = (candidate) => {
const settings = candidate ?? DEFAULT_LEVEL_SETTINGS;
if (!settings || typeof settings !== "object") return null;
const { gridSize, undoLimit, rerollLimit } = settings;
if (
!Number.isInteger(gridSize) ||
gridSize < 5 ||
gridSize > 9 ||
!Number.isInteger(undoLimit) ||
undoLimit < 0 ||
undoLimit > 9 ||
!Number.isInteger(rerollLimit) ||
rerollLimit < 0 ||
rerollLimit > 9
) {
return null;
}
return { gridSize, undoLimit, rerollLimit };
};
const makePlayerName = (room) => {
const usedNames = new Set(
[...room.players.values()].map((player) => player.name),
);
for (let attempt = 0; attempt < 100; attempt += 1) {
const name = `${NAME_ADJECTIVES[randomInt(NAME_ADJECTIVES.length)]} ${NAME_NOUNS[randomInt(NAME_NOUNS.length)]}`;
if (!usedNames.has(name)) return name;
}
return `Jolly Marble ${room.players.size + 1}`;
};
const send = (socket, message) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
}
};
const leaderboardFor = (room) =>
[...room.players.values()]
.map(({ token: _token, ...player }) => player)
.sort(
(left, right) =>
right.score - left.score ||
right.placed - left.placed ||
left.name.localeCompare(right.name),
);
const roomSnapshot = (room, socket, hostToken) => ({
type: "room-state",
roomId: room.id,
roundId: room.roundId,
isHost: hostToken === room.hostToken,
participants: room.clients.size,
history: room.history,
playerId: socket.playerToken,
playerName: room.players.get(socket.playerToken)?.name,
leaderboard: leaderboardFor(room),
settings: room.settings,
rerollsUsed: room.rerollsUsed,
});
export const attachDiceSyncServer = (httpServer) => {
const rooms = new Map();
const wss = new WebSocketServer({
server: httpServer,
path: "/sync",
maxPayload: 2048,
});
const broadcast = (room, message) => {
for (const client of room.clients) send(client, message);
};
const broadcastPresence = (room) => {
broadcast(room, { type: "presence", participants: room.clients.size });
};
const broadcastLeaderboard = (room) => {
broadcast(room, { type: "leaderboard", leaderboard: leaderboardFor(room) });
};
const leaveCurrentRoom = (socket) => {
const roomId = socket.roomId;
if (!roomId) return;
const room = rooms.get(roomId);
socket.roomId = undefined;
socket.hostToken = undefined;
if (!room) return;
room.clients.delete(socket);
const player = room.players.get(socket.playerToken);
if (player) {
player.connected = [...room.clients].some(
(client) => client.playerToken === socket.playerToken,
);
}
room.lastActiveAt = Date.now();
broadcastPresence(room);
broadcastLeaderboard(room);
};
const joinRoom = (socket, room, hostToken, requestedPlayerToken) => {
leaveCurrentRoom(socket);
let player = requestedPlayerToken
? room.players.get(requestedPlayerToken)
: null;
if (!player) {
const token = randomUUID();
player = {
id: token,
token,
name: makePlayerName(room),
score: 0,
placed: 0,
finished: false,
connected: true,
};
room.players.set(token, player);
}
player.connected = true;
socket.roomId = room.id;
socket.hostToken = hostToken;
socket.playerToken = player.token;
room.clients.add(socket);
room.lastActiveAt = Date.now();
send(socket, roomSnapshot(room, socket, hostToken));
send(socket, {
type: "player-token",
roomId: room.id,
playerToken: player.token,
});
broadcastPresence(room);
broadcastLeaderboard(room);
};
const createRoom = (socket, requestedSettings) => {
if (rooms.size >= MAX_ROOMS) {
send(socket, {
type: "error",
message: "The classroom server is full. Please try again shortly.",
});
return;
}
const settings = parseLevelSettings(requestedSettings);
if (!settings) {
send(socket, {
type: "error",
message: "Those level settings are not valid.",
});
return;
}
let id = makeRoomId();
while (rooms.has(id)) id = makeRoomId();
const hostToken = randomUUID();
const room = {
id,
hostToken,
roundId: randomUUID(),
clients: new Set(),
players: new Map(),
history: [],
settings,
rerollsUsed: 0,
createdAt: Date.now(),
lastActiveAt: Date.now(),
lastRollAt: 0,
};
rooms.set(id, room);
joinRoom(socket, room, hostToken);
send(socket, { type: "host-token", roomId: id, hostToken });
};
const requireHost = (socket) => {
const room = rooms.get(socket.roomId);
if (!room) {
send(socket, {
type: "error",
message: "This classroom room is no longer available.",
});
return null;
}
if (socket.hostToken !== room.hostToken) {
send(socket, {
type: "error",
message: "Only the room host can change the classroom game.",
});
return null;
}
return room;
};
wss.on("connection", (socket) => {
socket.on("message", (raw) => {
let message;
try {
message = JSON.parse(raw.toString());
} catch {
send(socket, {
type: "error",
message: "That classroom message could not be read.",
});
return;
}
if (message.type === "create") {
createRoom(socket, message.settings);
return;
}
if (message.type === "configure") {
const room = requireHost(socket);
if (!room) return;
if (room.history.length > 0) {
send(socket, {
type: "error",
message:
"Level settings can only be changed before the first roll.",
});
return;
}
const settings = parseLevelSettings(message.settings);
if (!settings) {
send(socket, {
type: "error",
message: "Those level settings are not valid.",
});
return;
}
room.settings = settings;
room.rerollsUsed = 0;
room.lastActiveAt = Date.now();
broadcast(room, {
type: "settings",
settings: room.settings,
rerollsUsed: room.rerollsUsed,
});
return;
}
if (message.type === "join") {
const roomId = String(message.roomId ?? "")
.trim()
.toUpperCase();
const room = rooms.get(roomId);
if (!room) {
send(socket, {
type: "error",
message: `No active classroom game was found for ${roomId || "that ID"}.`,
});
return;
}
const hostToken =
typeof message.hostToken === "string" ? message.hostToken : undefined;
const playerToken =
typeof message.playerToken === "string"
? message.playerToken
: undefined;
joinRoom(socket, room, hostToken, playerToken);
return;
}
if (message.type === "progress") {
const room = rooms.get(socket.roomId);
const player = room?.players.get(socket.playerToken);
if (!room || !player) return;
player.score = Math.max(
0,
Math.min(10000, Math.round(Number(message.score) || 0)),
);
player.placed = Math.max(
0,
Math.min(MAX_ROLLS, Math.round(Number(message.placed) || 0)),
);
player.finished = Boolean(message.finished);
room.lastActiveAt = Date.now();
broadcastLeaderboard(room);
return;
}
if (message.type === "roll") {
const room = requireHost(socket);
if (!room) return;
const now = Date.now();
if (now - room.lastRollAt < MIN_ROLL_INTERVAL_MS) return;
if (room.history.length >= MAX_ROLLS) {
send(socket, {
type: "error",
message:
"This round has reached its roll limit. Start a new round to continue.",
});
return;
}
const dice = [randomInt(1, 7), randomInt(1, 7)];
const roll = {
id: room.history.length + 1,
dice,
sum: dice[0] + dice[1],
rolledAt: now,
};
room.history.push(roll);
room.lastRollAt = now;
room.lastActiveAt = now;
broadcast(room, { type: "rolled", roundId: room.roundId, roll });
return;
}
if (message.type === "reroll") {
const room = requireHost(socket);
if (!room) return;
if (room.history.length === 0) {
send(socket, {
type: "error",
message: "Roll the dice before using a re-roll.",
});
return;
}
if (room.rerollsUsed >= room.settings.rerollLimit) {
send(socket, {
type: "error",
message: "This round has no re-rolls remaining.",
});
return;
}
if (
[...room.players.values()].some(
(player) => player.placed >= room.history.length,
)
) {
send(socket, {
type: "error",
message: "That roll has already been placed by a player.",
});
return;
}
const now = Date.now();
const dice = [randomInt(1, 7), randomInt(1, 7)];
const previousRoll = room.history.at(-1);
const roll = {
id: previousRoll.id,
dice,
sum: dice[0] + dice[1],
rolledAt: now,
};
room.history[room.history.length - 1] = roll;
room.rerollsUsed += 1;
room.lastActiveAt = now;
broadcast(room, {
type: "rerolled",
roundId: room.roundId,
roll,
rerollsUsed: room.rerollsUsed,
});
return;
}
if (message.type === "reset") {
const room = requireHost(socket);
if (!room) return;
room.roundId = randomUUID();
room.history = [];
room.rerollsUsed = 0;
for (const player of room.players.values()) {
player.score = 0;
player.placed = 0;
player.finished = false;
}
room.lastRollAt = 0;
room.lastActiveAt = Date.now();
broadcast(room, {
type: "round-reset",
roundId: room.roundId,
rerollsUsed: room.rerollsUsed,
});
broadcastLeaderboard(room);
}
});
socket.on("close", () => leaveCurrentRoom(socket));
});
const pruneTimer = setInterval(
() => {
const cutoff = Date.now() - ROOM_TTL_MS;
for (const [id, room] of rooms) {
if (room.clients.size === 0 && room.lastActiveAt < cutoff)
rooms.delete(id);
}
},
30 * 60 * 1000,
);
pruneTimer.unref();
wss.on("close", () => clearInterval(pruneTimer));
return { wss, rooms };
};

207
server/dice-sync.test.mjs Normal file
View file

@ -0,0 +1,207 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import test from "node:test";
import { WebSocket } from "ws";
import { attachDiceSyncServer } from "./dice-sync.mjs";
const createInbox = (socket) => {
const messages = [];
const waiters = [];
socket.on("message", (raw) => {
const message = JSON.parse(raw.toString());
const waiterIndex = waiters.findIndex(
(waiter) => waiter.type === message.type && waiter.predicate(message),
);
if (waiterIndex >= 0) {
const [waiter] = waiters.splice(waiterIndex, 1);
clearTimeout(waiter.timeout);
waiter.resolve(message);
} else {
messages.push(message);
}
});
return {
next(type, predicate = () => true) {
const messageIndex = messages.findIndex(
(message) => message.type === type && predicate(message),
);
if (messageIndex >= 0)
return Promise.resolve(messages.splice(messageIndex, 1)[0]);
return new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Timed out waiting for ${type}`)),
2000,
);
waiters.push({ type, predicate, resolve, timeout });
});
},
};
};
const connect = (url) =>
new Promise((resolve, reject) => {
const socket = new WebSocket(url);
socket.once("open", () => resolve(socket));
socket.once("error", reject);
});
test("a host creates a room and broadcasts one authoritative roll", async () => {
const httpServer = createServer();
const { wss } = attachDiceSyncServer(httpServer);
await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve));
const address = httpServer.address();
const url = `ws://127.0.0.1:${address.port}/sync`;
const host = await connect(url);
const guest = await connect(url);
const hostInbox = createInbox(host);
const guestInbox = createInbox(guest);
host.send(JSON.stringify({ type: "create" }));
const hostState = await hostInbox.next("room-state");
const tokenMessage = await hostInbox.next("host-token");
assert.equal(hostState.isHost, true);
assert.equal(tokenMessage.roomId, hostState.roomId);
guest.send(JSON.stringify({ type: "join", roomId: hostState.roomId }));
const guestState = await guestInbox.next("room-state");
assert.equal(guestState.isHost, false);
const hostRoll = hostInbox.next("rolled");
const guestRoll = guestInbox.next("rolled");
host.send(JSON.stringify({ type: "roll" }));
const [hostMessage, guestMessage] = await Promise.all([hostRoll, guestRoll]);
assert.deepEqual(guestMessage.roll, hostMessage.roll);
assert.equal(
hostMessage.roll.sum,
hostMessage.roll.dice[0] + hostMessage.roll.dice[1],
);
const forbidden = guestInbox.next("error");
guest.send(JSON.stringify({ type: "roll" }));
assert.match((await forbidden).message, /Only the room host/);
host.terminate();
guest.terminate();
await new Promise((resolve) => wss.close(resolve));
await new Promise((resolve) => httpServer.close(resolve));
});
test("level settings and re-rolls stay authoritative across a classroom", async () => {
const httpServer = createServer();
const { wss } = attachDiceSyncServer(httpServer);
await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve));
const address = httpServer.address();
const url = `ws://127.0.0.1:${address.port}/sync`;
const host = await connect(url);
const guest = await connect(url);
const hostInbox = createInbox(host);
const guestInbox = createInbox(guest);
host.send(
JSON.stringify({
type: "create",
settings: { gridSize: 9, undoLimit: 0, rerollLimit: 2 },
}),
);
const hostState = await hostInbox.next("room-state");
await hostInbox.next("host-token");
assert.deepEqual(hostState.settings, {
gridSize: 9,
undoLimit: 0,
rerollLimit: 2,
});
assert.equal(hostState.rerollsUsed, 0);
guest.send(JSON.stringify({ type: "join", roomId: hostState.roomId }));
const guestState = await guestInbox.next("room-state");
assert.deepEqual(guestState.settings, hostState.settings);
const configuredSettings = {
gridSize: 8,
undoLimit: 4,
rerollLimit: 1,
};
const hostSettings = hostInbox.next("settings");
const guestSettings = guestInbox.next("settings");
host.send(
JSON.stringify({ type: "configure", settings: configuredSettings }),
);
const [hostSettingsMessage, guestSettingsMessage] = await Promise.all([
hostSettings,
guestSettings,
]);
assert.deepEqual(hostSettingsMessage.settings, configuredSettings);
assert.deepEqual(guestSettingsMessage.settings, configuredSettings);
const forbiddenConfiguration = guestInbox.next("error");
guest.send(
JSON.stringify({
type: "configure",
settings: { gridSize: 5, undoLimit: 3, rerollLimit: 3 },
}),
);
assert.match((await forbiddenConfiguration).message, /Only the room host/);
const firstHostRoll = hostInbox.next("rolled");
const firstGuestRoll = guestInbox.next("rolled");
host.send(JSON.stringify({ type: "roll" }));
const [hostRollMessage] = await Promise.all([firstHostRoll, firstGuestRoll]);
const hostReroll = hostInbox.next("rerolled");
const guestReroll = guestInbox.next("rerolled");
host.send(JSON.stringify({ type: "reroll" }));
const [hostRerollMessage, guestRerollMessage] = await Promise.all([
hostReroll,
guestReroll,
]);
assert.deepEqual(guestRerollMessage.roll, hostRerollMessage.roll);
assert.equal(hostRerollMessage.roll.id, hostRollMessage.roll.id);
assert.equal(hostRerollMessage.rerollsUsed, 1);
const exhausted = hostInbox.next("error");
host.send(JSON.stringify({ type: "reroll" }));
assert.match((await exhausted).message, /no re-rolls remaining/);
const firstHostReset = hostInbox.next("round-reset");
const firstGuestReset = guestInbox.next("round-reset");
host.send(JSON.stringify({ type: "reset" }));
const [resetMessage] = await Promise.all([firstHostReset, firstGuestReset]);
assert.equal(resetMessage.rerollsUsed, 0);
const secondHostRoll = hostInbox.next("rolled");
const secondGuestRoll = guestInbox.next("rolled");
host.send(JSON.stringify({ type: "roll" }));
await Promise.all([secondHostRoll, secondGuestRoll]);
const placedUpdate = hostInbox.next("leaderboard", (message) =>
message.leaderboard.some(
(player) => player.id === guestState.playerId && player.placed === 1,
),
);
guest.send(
JSON.stringify({ type: "progress", score: 8, placed: 1, finished: false }),
);
await placedUpdate;
const alreadyPlaced = hostInbox.next("error");
host.send(JSON.stringify({ type: "reroll" }));
assert.match((await alreadyPlaced).message, /already been placed/);
const secondHostReset = hostInbox.next("round-reset");
const secondGuestReset = guestInbox.next("round-reset");
host.send(JSON.stringify({ type: "reset" }));
await Promise.all([secondHostReset, secondGuestReset]);
const newcomer = await connect(url);
const newcomerInbox = createInbox(newcomer);
newcomer.send(JSON.stringify({ type: "join", roomId: hostState.roomId }));
const newcomerState = await newcomerInbox.next("room-state");
assert.deepEqual(newcomerState.settings, configuredSettings);
assert.equal(newcomerState.rerollsUsed, 0);
host.terminate();
guest.terminate();
newcomer.terminate();
await new Promise((resolve) => wss.close(resolve));
await new Promise((resolve) => httpServer.close(resolve));
});

99
server/index.mjs Normal file
View file

@ -0,0 +1,99 @@
import { createReadStream, existsSync } from "node:fs";
import { stat } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { attachDiceSyncServer } from "./dice-sync.mjs";
const serverDir = path.dirname(fileURLToPath(import.meta.url));
const distDir = path.resolve(serverDir, "../dist");
const port = Number(process.env.PORT || 3000);
const contentTypes = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
};
const sendFile = async (request, response, filePath) => {
const fileStat = await stat(filePath);
response.writeHead(200, {
"Content-Type":
contentTypes[path.extname(filePath)] || "application/octet-stream",
"Content-Length": fileStat.size,
"Cache-Control": filePath.endsWith("index.html")
? "no-cache"
: "public, max-age=31536000, immutable",
});
if (request.method === "HEAD") response.end();
else createReadStream(filePath).pipe(response);
};
const httpServer = createServer(async (request, response) => {
try {
const requestUrl = new URL(request.url || "/", "http://localhost");
if (requestUrl.pathname === "/api/health") {
response.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
});
response.end(JSON.stringify({ ok: true }));
return;
}
if (!["GET", "HEAD"].includes(request.method || "")) {
response.writeHead(405, { Allow: "GET, HEAD" });
response.end("Method not allowed");
return;
}
const decodedPath = decodeURIComponent(requestUrl.pathname);
const requestedFile = path.resolve(distDir, `.${decodedPath}`);
const safePath = requestedFile.startsWith(`${distDir}${path.sep}`)
? requestedFile
: path.join(distDir, "index.html");
let filePath = path.join(distDir, "404.html");
if (existsSync(safePath)) {
const safeStat = await stat(safePath);
if (safeStat.isFile()) filePath = safePath;
else if (
safeStat.isDirectory() &&
existsSync(path.join(safePath, "index.html"))
)
filePath = path.join(safePath, "index.html");
}
await sendFile(request, response, filePath);
} catch {
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Internal server error");
}
});
attachDiceSyncServer(httpServer);
httpServer.listen(port, "0.0.0.0", () => {
console.log(`Interactives is listening on port ${port}`);
if (
typeof process.getuid === "function" &&
process.getuid() === 0 &&
typeof process.setuid === "function"
) {
try {
process.setuid("node");
} catch {
// The container can still run safely with its filesystem mounted read-only by the platform.
}
}
});
const shutdown = () => httpServer.close(() => process.exit(0));
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);