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

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
.git
.astro
.DS_Store
dist
node_modules
npm-debug.log*

18
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,18 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run ci

29
Dockerfile Normal file
View file

@ -0,0 +1,29 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run ci
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=80
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
COPY server ./server
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q --spider http://127.0.0.1/api/health || exit 1
CMD ["node", "server/index.mjs"]

View file

@ -21,6 +21,10 @@ npm run dev
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Forgejo and Dokploy
The `forgejo` remote points to `Websites/interactives`. Forgejo Actions runs lint, the classroom synchronization test, and the Astro production build on every push to `main`. Dokploy builds the checked-in `Dockerfile`; the container serves the static Astro site and the `/sync` WebSocket endpoint on port 80.
## Tech Stack ## Tech Stack
- Astro - Astro

View file

@ -5,8 +5,26 @@ import sitemap from "@astrojs/sitemap";
import react from "@astrojs/react"; import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { attachDiceSyncServer } from "./server/dice-sync.mjs";
// Admin API runs as a standalone Netlify Function (see netlify/functions/admin.mts) // Admin API runs as a standalone Netlify Function (see netlify/functions/admin.mts)
const attachedServers = new WeakSet();
const classroomSync = () => ({
name: "classroom-dice-sync",
configureServer(server) {
if (server.httpServer && !attachedServers.has(server.httpServer)) {
attachDiceSyncServer(server.httpServer);
attachedServers.add(server.httpServer);
}
},
configurePreviewServer(server) {
if (server.httpServer && !attachedServers.has(server.httpServer)) {
attachDiceSyncServer(server.httpServer);
attachedServers.add(server.httpServer);
}
},
});
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
site: "https://interactives.neeldhara.com", site: "https://interactives.neeldhara.com",
@ -22,14 +40,14 @@ export default defineConfig({
], ],
vite: { vite: {
plugins: [tailwindcss()], plugins: [tailwindcss(), classroomSync()],
build: { build: {
target: "es2022", target: "es2022",
minify: "esbuild", minify: "esbuild",
// Sanitize chunk filenames — Netlify esbuild chokes on ! and ~ in names // Sanitize chunk filenames — Netlify esbuild chokes on ! and ~ in names
rollupOptions: { rollupOptions: {
output: { output: {
sanitizeFileName: (name) => name.replace(/[^\w./-]/g, '_'), sanitizeFileName: (name) => name.replace(/[^\w./-]/g, "_"),
}, },
}, },
}, },

24
package-lock.json generated
View file

@ -47,7 +47,8 @@
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"three": "^0.183.2", "three": "^0.183.2",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vaul": "^1.1.2" "vaul": "^1.1.2",
"ws": "^8.21.1"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/mdx": "^5.0.3", "@astrojs/mdx": "^5.0.3",
@ -21326,6 +21327,27 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0" "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
} }
}, },
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/wsl-utils": { "node_modules/wsl-utils": {
"version": "0.3.1", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",

View file

@ -6,12 +6,15 @@
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"ci": "npm run test:sync && npm run build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro", "lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx,.astro --fix", "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx,.astro --fix",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check ." "format:check": "prettier --check .",
"start": "node server/index.mjs",
"test:sync": "node --test server/dice-sync.test.mjs"
}, },
"engines": { "engines": {
"node": ">=22.12.0" "node": ">=22.12.0"
@ -56,7 +59,8 @@
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"three": "^0.183.2", "three": "^0.183.2",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vaul": "^1.1.2" "vaul": "^1.1.2",
"ws": "^8.21.1"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/mdx": "^5.0.3", "@astrojs/mdx": "^5.0.3",

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);

View file

@ -1,6 +1,7 @@
import React, { type ComponentType,lazy, Suspense } from "react"; import React, { type ComponentType,lazy, Suspense } from "react";
const componentMap: Record<string, () => Promise<{ default: ComponentType<any> }>> = { const componentMap: Record<string, () => Promise<{ default: ComponentType<any> }>> = {
"akkam-bakkam": () => import("./interactives/AkkamBakkam"),
"binary-number-game": () => import("./interactives/BinaryNumberGame"), "binary-number-game": () => import("./interactives/BinaryNumberGame"),
"ternary-number-game": () => import("./interactives/TernaryNumberGame"), "ternary-number-game": () => import("./interactives/TernaryNumberGame"),
"balanced-ternary-game": () => import("./interactives/BalancedTernaryGame"), "balanced-ternary-game": () => import("./interactives/BalancedTernaryGame"),

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,312 @@
import { useCallback, useEffect, useRef, useState } from "react";
export interface DiceRoll {
id: number;
dice: [number, number];
sum: number;
rolledAt: number;
}
export interface LevelSettings {
gridSize: number;
undoLimit: number;
rerollLimit: number;
}
export const DEFAULT_LEVEL_SETTINGS: LevelSettings = {
gridSize: 5,
undoLimit: 3,
rerollLimit: 3,
};
type ConnectionStatus =
| "solo"
| "connecting"
| "connected"
| "reconnecting"
| "error";
export interface LeaderboardPlayer {
id: string;
name: string;
score: number;
placed: number;
finished: boolean;
connected: boolean;
}
interface ClassroomState {
roomId: string | null;
roundId: string | null;
isHost: boolean;
participants: number;
history: DiceRoll[];
playerId: string | null;
playerName: string | null;
leaderboard: LeaderboardPlayer[];
settings: LevelSettings;
rerollsUsed: number;
status: ConnectionStatus;
error: string | null;
}
const initialState: ClassroomState = {
roomId: null,
roundId: null,
isHost: false,
participants: 0,
history: [],
playerId: null,
playerName: null,
leaderboard: [],
settings: DEFAULT_LEVEL_SETTINGS,
rerollsUsed: 0,
status: "solo",
error: null,
};
const hostTokenKey = (roomId: string) => `akkam-bakkam-host-${roomId}`;
const playerTokenKey = (roomId: string) => `akkam-bakkam-player-${roomId}`;
export const useClassroomDice = () => {
const [state, setState] = useState<ClassroomState>(initialState);
const socketRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<number | null>(null);
const manualCloseRef = useRef(false);
const desiredRoomRef = useRef<string | null>(null);
const clearReconnectTimer = () => {
if (reconnectTimerRef.current !== null)
window.clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
};
const connect = useCallback(
(
action: "create" | "join",
roomId?: string,
settings: LevelSettings = DEFAULT_LEVEL_SETTINGS,
) => {
clearReconnectTimer();
manualCloseRef.current = false;
socketRef.current?.close();
const normalizedRoomId = roomId?.trim().toUpperCase();
desiredRoomRef.current = normalizedRoomId || null;
setState((current) => ({
...current,
status: current.roomId ? "reconnecting" : "connecting",
error: null,
}));
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${window.location.host}/sync`);
socketRef.current = socket;
socket.addEventListener("open", () => {
if (action === "create") {
socket.send(JSON.stringify({ type: "create", settings }));
} else {
const hostToken = normalizedRoomId
? sessionStorage.getItem(hostTokenKey(normalizedRoomId))
: null;
const playerToken = normalizedRoomId
? sessionStorage.getItem(playerTokenKey(normalizedRoomId))
: null;
socket.send(
JSON.stringify({
type: "join",
roomId: normalizedRoomId,
hostToken,
playerToken,
}),
);
}
});
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.type === "room-state") {
desiredRoomRef.current = message.roomId;
setState({
roomId: message.roomId,
roundId: message.roundId,
isHost: message.isHost,
participants: message.participants,
history: message.history,
playerId: message.playerId,
playerName: message.playerName,
leaderboard: message.leaderboard,
settings: message.settings ?? DEFAULT_LEVEL_SETTINGS,
rerollsUsed: message.rerollsUsed ?? 0,
status: "connected",
error: null,
});
} else if (message.type === "host-token") {
sessionStorage.setItem(
hostTokenKey(message.roomId),
message.hostToken,
);
setState((current) => ({ ...current, isHost: true }));
} else if (message.type === "player-token") {
sessionStorage.setItem(
playerTokenKey(message.roomId),
message.playerToken,
);
} else if (message.type === "presence") {
setState((current) => ({
...current,
participants: message.participants,
}));
} else if (message.type === "settings") {
setState((current) => ({
...current,
settings: message.settings,
rerollsUsed: message.rerollsUsed ?? current.rerollsUsed,
error: null,
}));
} else if (message.type === "rolled") {
setState((current) =>
current.roundId === message.roundId
? {
...current,
history: [...current.history, message.roll],
error: null,
}
: current,
);
} else if (message.type === "rerolled") {
setState((current) =>
current.roundId === message.roundId && current.history.length > 0
? {
...current,
history: [...current.history.slice(0, -1), message.roll],
rerollsUsed: message.rerollsUsed,
error: null,
}
: current,
);
} else if (message.type === "round-reset") {
setState((current) => ({
...current,
roundId: message.roundId,
history: [],
rerollsUsed: message.rerollsUsed ?? 0,
error: null,
}));
} else if (message.type === "leaderboard") {
setState((current) => ({
...current,
leaderboard: message.leaderboard,
}));
} else if (message.type === "error") {
setState((current) => ({
...current,
status: current.roomId ? current.status : "error",
error: message.message,
}));
}
});
socket.addEventListener("close", () => {
if (socketRef.current !== socket || manualCloseRef.current) return;
const roomToRejoin = desiredRoomRef.current;
if (!roomToRejoin) {
setState((current) => ({
...current,
status: "error",
error: current.error || "Could not reach the classroom server.",
}));
return;
}
setState((current) => ({ ...current, status: "reconnecting" }));
reconnectTimerRef.current = window.setTimeout(
() => connect("join", roomToRejoin),
1500,
);
});
socket.addEventListener("error", () => {
setState((current) => ({
...current,
error: "The classroom connection was interrupted.",
}));
});
},
[],
);
const createRoom = useCallback(
(settings: LevelSettings = DEFAULT_LEVEL_SETTINGS) =>
connect("create", undefined, settings),
[connect],
);
const joinRoom = useCallback(
(roomId: string) => connect("join", roomId),
[connect],
);
const leaveRoom = useCallback(() => {
clearReconnectTimer();
manualCloseRef.current = true;
desiredRoomRef.current = null;
socketRef.current?.close();
socketRef.current = null;
setState(initialState);
}, []);
const roll = useCallback(() => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: "roll" }));
}
}, []);
const configure = useCallback((settings: LevelSettings) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: "configure", settings }));
}
}, []);
const reroll = useCallback(() => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: "reroll" }));
}
}, []);
const reset = useCallback(() => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: "reset" }));
}
}, []);
const updateProgress = useCallback(
(score: number, placed: number, finished: boolean) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify({ type: "progress", score, placed, finished }),
);
}
},
[],
);
useEffect(
() => () => {
manualCloseRef.current = true;
clearReconnectTimer();
socketRef.current?.close();
},
[],
);
return {
...state,
createRoom,
joinRoom,
leaveRoom,
roll,
configure,
reroll,
reset,
updateProgress,
};
};

View file

@ -3,6 +3,7 @@ import type { ComponentType } from "react";
type LazyImport = () => Promise<{ default: ComponentType<any> }>; type LazyImport = () => Promise<{ default: ComponentType<any> }>;
export const componentMap: Record<string, LazyImport> = { export const componentMap: Record<string, LazyImport> = {
"akkam-bakkam": () => import("@/components/interactives/AkkamBakkam"),
"binary-number-game": () => import("@/components/interactives/BinaryNumberGame"), "binary-number-game": () => import("@/components/interactives/BinaryNumberGame"),
"ternary-number-game": () => import("@/components/interactives/TernaryNumberGame"), "ternary-number-game": () => import("@/components/interactives/TernaryNumberGame"),
"balanced-ternary-game": () => import("@/components/interactives/BalancedTernaryGame"), "balanced-ternary-game": () => import("@/components/interactives/BalancedTernaryGame"),

View file

@ -390,6 +390,17 @@ export const allInteractives: Interactive[] = [
status: "published" as const, status: "published" as const,
}, },
// === Games === // === Games ===
{
slug: "akkam-bakkam",
title: "Akkam Bakkam",
description:
"Place shared dice sums on a private grid, score equal neighbors, and play live with a classroom leaderboard.",
tags: ["dice", "strategy", "classroom", "synchronized", "grid-game"],
themes: ["Games"],
dateAdded: "2026-07-20",
hasGreenScreen: false,
status: "published" as const,
},
{ {
slug: "game-of-sim", slug: "game-of-sim",
title: "Game of Sim", title: "Game of Sim",

View file

@ -12,6 +12,20 @@
@custom-variant fhd (@media (min-width: 1920px)); @custom-variant fhd (@media (min-width: 1920px));
@custom-variant uw (@media (min-width: 2250px)); @custom-variant uw (@media (min-width: 2250px));
@keyframes akkam-die-arrival {
0% { opacity: 0; transform: translateY(-8px) rotate(-8deg) scale(0.9); }
65% { opacity: 1; transform: translateY(2px) rotate(2deg) scale(1.03); }
100% { opacity: 1; transform: translateY(0) rotate(0) scale(1); }
}
.akkam-die-arrival {
animation: akkam-die-arrival 280ms ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.akkam-die-arrival { animation: none; }
}
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); /* #FFFFFF */ --background: oklch(1 0 0); /* #FFFFFF */
@ -407,4 +421,4 @@
body { body {
position: relative; position: relative;
overflow-x: hidden; overflow-x: hidden;
} }