Project name: replit-com.
Replit project/join URL: https://replit.com/join#t=ykbwmstneuzwcyn-otolorincyber2
A multiplayer social deduction game played on real Nigerian university campuses. Players are Crew or Impostor. Crew complete tasks around the campus. The Impostor sabotages and tries to survive. Everyone can call meetings and vote. Audience members (especially on Twitch) can watch and predict. The game lives on catinlens.com.
CAMP-US
CAMP-US / WHO BE THE FAKE? Project Record
Everything discussed and built in this conversation, as of 11 October 2026.
The spec (Web Social Deduction Architecture) asked for:
CAMP-US — Full Game Specification
Claude finished the response
<div class="panel header">
<span>CAMP-US</span>
<strong id="room-code">ROOM</strong>
<span class="timer" id="timer"></span>
</div>
<div class="canvas-wrap" id="canvas-wrap"><canvas id="gameCanvas"></canvas></div>
</div>
<div class="sidebar" id="side"></div>
</div>
let sess = null, S = null, skew = 0, lastKey = '', busy = false, msgT = 0, nodes = [];
}
function renderGame() {
$('room-code').textContent = 'ROOM ' + S.code;
const t = $('timer');
const end = S.phase === 'meeting' ? S.meeting && S.meeting.ends : S.phase === 'mission' ? S.timer : null;
if (end) t.dataset.end = end;
else { delete t.dataset.end; t.textContent = S.phase === 'results' ? 'Over' : ''; }
drawMap();
const main = S.phase === 'results' ? [resultsPanel()] : S.phase === 'meeting' ? [meetingPanel()] : missionPanels();
// ---- Map ----
if (!S || !S.locations) return;
const locs = S.locations, n = locs.length, cx = w / 2, cy = ht / 2, r = Math.max(40, Math.min(w, ht) / 2 - 52), pos = {};
locs.forEach((l, i) => {
const a = -Math.PI / 2 + 2 * Math.PI * i / n;
pos[l.id] = { id: l.id, x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) };
nodes.push(pos[l.id]);
});
ctx.strokeStyle = COL.line; ctx.lineWidth = 1.5;
CAMP-US — Full Game Specification
Each campus has its own map and locations. The rules stay the same.
identity-driven experience, we need to embed that psychology straight into the game loop so it shapes how players think, lie, and form rivalries.
Combining the raw freedom of choice (Option A) with the persistent identity rewards (Option B) creates the perfect balance. The goal is to make the game mechanics force social tension while rewarding players for developing a unique gaming persona.
| Game Loop Stage | Core Game Mechanic | Psychological Trigger | Addictive Hook |
|---|---|---|---|
| Lobby Matchmaking | Passport Profile Display | Pre-existing Bias & Paranoia | Players work to curate their "Legendary Deceiver" or "Trusted Ally" profile. |
| Live Round Phase | Split-Focus Tasks & Sabotage | High Cognitive Load & Vulnerability | The thrill of pulling off a execution while the victim is typing an expense report. |
| Tribunal Phase | Spotlight Intimidation & Emotes | Social Dynamics & Threat Defense | The dopamine rush of successfully shifting the blame to an innocent bystander. |
• Original request: add games to a Larix overlay, the way OBS has a browser source, so an audience can play along. The streamer plays on another device. The idea is an Among Us style game.
• Working model: host the game page at a URL, show it as the Larix overlay, and control the game from the other device through the same URL or room. The game needs a server that syncs state between devices.
• A comparison matrix of standard architecture versus the low friction web design.
The pasted engine had rooms, roles, tasks, sabotage, meetings, voting and audience predictions, with campuses UNILAG, OAU, UI and Babcock. Good choice: discrete locations instead of 2D coordinates. Bugs found:
What each viewer sees: roster (names, alive only after a meeting reveals deaths), me (role, tasks, cooldown times, fellow impostors), here (only living players and bodies in the viewer's own location), spectator (full map for audience, ghosts and the results screen, no roles until results), task progress, meeting info, sabotage info, feed, timer, winner, and now (server time, added so countdowns are correct despite phone clock differences).
Title in the code: WHO BE THE FAKE? UNILAG Edition. One file of 148,374 bytes and about 2,120 lines, no network calls (only a link to share on Twitter), so it runs as a static page.
The strawman note: the spec's standard column described games more clumsily than they are (Among Us uses a short room code, not launchers and version checks), and the low friction column mostly listed things the author would have to build.
HIDE_AND_SEEK.html is better as a game: complete and playable, real time movement, minigames, richer sabotage, Pidgin chat and reactive bots, art, sound and progression, and no server. The CAMP-US work is better only as a backbone for real multiplayer: a server holding the truth, secret tokens, per player visibility, audience view, origin and rate limit protection. It is a bare interface with circles and buttons today.
A multiplayer social deduction game played on real Nigerian university campuses. Players are Crew or Impostor. Crew complete tasks around the campus. The Impostor sabotages and tries to survive. Everyone can call meetings and vote. Audience members (especially on Twitch) can watch and predict. The game lives on catinlens.com.
.game {
display: grid; grid-template-columns: 1fr 320px; gap: 16px;
height: 100vh; height: 100dvh;
padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right))
max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
}
.main { display: flex; flex-direction: column; gap: 12px; min-width: 0; min-height: 0; }
.sidebar { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; min-height: 0; }
<div id="lobby" class="panel center" hidden></div> <div class="game" id="game" hidden> <div class="main"><div class="panel header">
<span>CAMP-US</span>
<strong id="room-code">ROOM</strong>
<span class="timer" id="timer"></span>
</div>
<div class="canvas-wrap" id="canvas-wrap"><canvas id="gameCanvas"></canvas></div>
</div>
<div class="sidebar" id="side"></div>
</div>
const here = S.here || { players: [], bodies: [] }, acts = [];
if (here.players.length) acts.push(H('p', { class: 'muted' }, 'Here with you: ' + here.players.map(p => p.name).join(', ')));
if (me.alive && me.role === 'impostor') {
const targets = here.players.filter(p => !me.partners.some(x => x.id === p.id));
// ---- Map ----
• Working model: host the game page at a URL, show it as the Larix overlay, and control the game from the other device through the same URL or room. The game needs a server that syncs state between devices.
• A Twitch Extension overlay with predictions, wagers and vanity tokens.
• A comparison matrix of standard architecture versus the low friction web design.
• The spec says server side randomness while the matrix says client side drops. Keep it server side. A 5 percent radar drop can break balance.
The pasted engine had rooms, roles, tasks, sabotage, meetings, voting and audience predictions, with campuses UNILAG, OAU, UI and Babcock. Good choice: discrete locations instead of 2D coordinates. Bugs found:
• Real time 2D canvas game: you plus 6 bots picked from a roster (Tunde, Chiamaka, Bolu, Emeka, Aisha, Kelechi, Zainab), one of them the Fake. The player is the Fake about a third of rounds, guaranteed after three rounds without.
• Multiplayer hooks the author left: each actor has ctrl local or ai (remote planned) and game changing moves go through an Actions object. But all state is inside one closure and the browser holds who the Fake is, so real multiplayer needs a server in charge.
• UNILAG: a labelled map including the Lagoon, Main Library, Senate, Jaja Hall, Moremi Hall, Sports Centre and others. The game's layout is stylized, not geographic.
• UI (Ibadan): SUB, S. Bello Hall, Inde Hall, Awo Hall, Agric, Vet Med, UI Zoo, Awba Dam and the Zoo and archive areas.
• Babcock tour map with dashed routes: Main Gate, Pioneer SDA Church, Shopping Mall, Registry, Senate, SAT, BBS, Cafeteria, Amphitheatre, Academic Building, Stadium, and hostels such as Havillah Gold, Winslow, Emerald, White Hall, Crystal, Platinum and Diamond. Labels as read from the image.
The strawman note: the spec's standard column described games more clumsily than they are (Among Us uses a short room code, not launchers and version checks), and the low friction column mostly listed things the author would have to build.
HIDE_AND_SEEK.html is better as a game: complete and playable, real time movement, minigames, richer sabotage, Pidgin chat and reactive bots, art, sound and progression, and no server. The CAMP-US work is better only as a backbone for real multiplayer: a server holding the truth, secret tokens, per player visibility, audience view, origin and rate limit protection. It is a bare interface with circles and buttons today.
<label>Your name <input id="name" maxlength="20" autocomplete="nickname"></label>
<label>Room code <input id="code" maxlength="8" autocapitalize="characters" autocomplete="off"></label>
<div class="row"><button class="primary" id="b-join">Join room</button><button id="b-watch">Watch</button></div><label>Campus for a new room
<select id="campus"><option value="unilag">UNILAG</option><option value="oau">OAU</option><option value="ui">UI</option><option value="babcock">Babcock</option></select>
</label>
<button id="b-create">Create room</button>
</div>const store = {
get(k) { try { return JSON.parse(localStorage.getItem(k)); } catch (e) { return null; } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} },
del(k) { try { localStorage.removeItem(k); } catch (e) {} },
};
// Tiny element builder. Text is always added as text, never as HTML.
function H(tag, attrs, ...kids) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k === 'class') e.className = v;
else if (k.startsWith('on')) e.addEventListener(k.slice(2), v);
else if (k.startsWith('data-')) e.setAttribute(k, v);
else e[k] = v;
}
for (const c of kids.flat()) if (c != null && c !== false) e.append(c.nodeType ? c : String(c));
return e;
}
function setMsg(t) {
$('msg').textContent = t || '';
clearTimeout(msgT);
if (t) msgT = setTimeout(() => { $('msg').textContent = ''; }, 4000);
}
// ---- State ----
function apply(st) {
skew = st.now - Date.now();
const { now, status, ...rest } = st;
S = st;
const key = JSON.stringify(rest);
if (key !== lastKey) { lastKey = key; render(); }
}
async function poll() {
if (!sess || busy) return;
busy = true;
const r = await api('/api/rooms/state', { code: sess.code, sessionToken: sess.token });
busy = false;
if (!sess) return;
if (r.status === 404) return leaveLocal('That room ended, or your session expired.');
if (r.status === 0) return setMsg('Connection lost. Retrying...');
if (r.phase) apply(r); else if (r.error) setMsg(r.error);
}
function leaveLocal(note) {
sess = null; S = null; lastKey = '';
store.del('campus-session');
history.replaceState(null, '', location.pathname);
render();
if (note) setMsg(note);
}
async function enter(kind) {
const name = $('name').value.trim(), code = $('code').value.trim().toUpperCase();
if (kind !== 'watch' && !name) return setMsg('Enter your name first.');
if (kind !== 'create' && !code) return setMsg('Enter a room code.');
let r;
if (kind === 'create') r = await api('/api/rooms/create', { displayName: name, campus: $('campus').value });
else if (kind === 'join') r = await api('/api/rooms/join', { code, displayName: name });
else r = await api('/api/rooms/audience', { code, displayName: name || undefined });
if (r.error) return setMsg(r.error);
sess = { code: r.code || code, token: r.sessionToken || null, audienceId: r.audienceId || null };
store.set('campus-session', sess);
history.replaceState(null, '', '?code=' + sess.code);
apply(r.room);
}
async function act(action, payload) {
const r = await api('/api/rooms/action', { code: sess.code, sessionToken: sess.token, action, payload });
if (r.error) setMsg(r.error);
else if (r.left) leaveLocal();
else if (r.room) apply(r.room);
}
async function predict(p) {
const r = await api('/api/rooms/action', { code: sess.code, action: 'audience-prediction', payload: { audienceId: sess.audienceId, prediction: p } });
setMsg(r.error || 'Prediction saved');
}
// ---- Rendering ----
const panel = (...k) => H('div', { class: 'panel' }, ...k);
const locName = id => S.locations.find(l => l.id === id)?.name || id;
const feedPanel = () => panel(H('h4', {}, 'Feed'), H('div', { class: 'list' }, S.feed.slice(0, 8).map(f => H('p', { class: 'muted' }, f))));
const leaveBtn = () => H('button', { onclick: () => {
if (!confirm('Leave this game?')) return;
if (sess.token) act('leave'); else leaveLocal();
} }, 'Leave');
const predictRow = () => sess.audienceId ? H('div', { class: 'row' },
H('span', { class: 'muted' }, 'Who wins?'),
H('button', { onclick: () => predict('crew') }, 'Crew'),
H('button', { onclick: () => predict('impostor') }, 'Impostors')) : null;
function render() {
const lobby = !!S && S.phase === 'lobby';
$('entry').hidden = !!(S || sess);
$('lobby').hidden = !lobby;
$('game').hidden = !(S && !lobby);
if (!S) return;
if (lobby) renderLobby(); else renderGame();
}
$('side').replaceChildren(...main, feedPanel(), leaveBtn());
}
function resultsPanel() {
return panel(
H('h3', {}, S.winner === 'crew' ? 'Crew wins' : 'Impostors win'),
H('div', { class: 'list' }, S.roster.map(p => H('div', { class: 'task' },
H('span', {}, p.name), H('span', { class: 'muted' }, (p.role || '') + (p.alive ? '' : ' (out)'))))),
S.me && S.me.isHost
? H('button', { class: 'primary', onclick: () => act('restart') }, 'Play again')
: H('p', { class: 'muted' }, 'Waiting for the host to restart.'));
}
function meetingPanel() {
const me = S.me, voted = S.meeting.voted;
const canVote = !!me && me.alive && !voted.includes(me.id);
return panel(
H('h3', {}, S.meeting.caller + ' called a meeting'),
H('p', { class: 'muted' }, me && me.alive ? (canVote ? 'Who is the impostor?' : 'Vote cast. Waiting for the others.') : 'Only living players vote.'),
H('div', { class: 'list' }, S.roster.filter(p => p.alive).map(p => H('div', { class: 'task' },
H('span', {}, p.name + (voted.includes(p.id) ? ' (voted)' : '')),
me && p.id !== me.id ? H('button', { disabled: !canVote, onclick: () => act('vote', { targetId: p.id }) }, 'Vote') : null))),
canVote ? H('button', { onclick: () => act('vote', { targetId: 'skip' }) }, 'Skip vote') : null);
}
function missionPanels() {
const me = S.me, tp = S.taskProgress, out = [];
const fill = H('div');
fill.style.width = (tp.total ? Math.round(100 * tp.done / tp.total) : 0) + '%';
out.push(panel(H('h4', {}, 'Crew tasks ' + tp.done + '/' + tp.total), H('div', { class: 'bar' }, fill)));
if (S.sabotage) out.push(H('div', { class: 'panel alert' },
H('h3', {}, S.sabotage.label),
H('p', { class: 'muted' }, 'Fix point: ' + S.sabotage.fixAtName + '. Ends in ', H('span', { class: 'timer', 'data-end': S.sabotage.ends }))));
if (!me) {
out.push(panel(H('h3', {}, 'Watching'), H('p', { class: 'muted' }, 'You can see every player on the map.'), predictRow()));
return out;
}
out.push(panel(
H('h3', {}, (me.role === 'impostor' ? 'You are an Impostor' : 'You are Crew') + (me.alive ? '' : ' (out)')),
H('p', { class: 'muted' }, 'You are at ' + locName(me.location)),
me.partners.length ? H('p', { class: 'muted' }, 'Other impostors: ' + me.partners.map(p => p.name).join(', ')) : null,
!me.alive ? H('p', { class: 'muted' }, me.role === 'crew' ? 'You are a ghost. Finishing tasks still helps the crew.' : 'You are out of the round.') : null));
const hold = e => (S.sabotage && S.sabotage.effect === e) ? '1' : null;
if (me.role === 'crew') {
const taskRow = t => {
const at = me.location === t.location, active = me.activeTask && me.activeTask.id === t.id;
const btn = !at ? H('button', { disabled: true }, 'Go there')
: active ? H('button', { class: 'primary', 'data-label': 'Finish', 'data-ready': me.activeTask.readyAt, 'data-hold': hold('no-tasks'), onclick: () => act('complete-task', { taskId: t.id }) }, 'Finish')
: H('button', { 'data-label': 'Start', 'data-hold': hold('no-tasks'), onclick: () => act('start-task', { taskId: t.id }) }, 'Start');
return H('div', { class: 'task' }, H('span', {}, t.label), btn);
};
out.push(panel(H('h4', {}, 'Your tasks'), H('div', { class: 'list' }, me.tasks.length ? me.tasks.map(taskRow) : H('p', { class: 'muted' }, 'All done.'))));
}
out.push(panel(H('h4', {}, 'Move to'), H('div', { class: 'row' }, (S.links[me.location] || []).map(id =>
H('button', { 'data-label': locName(id), 'data-ready': me.moveReadyAt, 'data-hold': hold('no-move'), onclick: () => act('move', { location: id }) }, locName(id))))));
const here = S.here || { players: [], bodies: [] }, acts = [];
if (here.players.length) acts.push(H('p', { class: 'muted' }, 'Here with you: ' + here.players.map(p => p.name).join(', ')));
if (me.alive && me.role === 'impostor') {
const targets = here.players.filter(p => !me.partners.some(x => x.id === p.id));
acts.push(H('div', { class: 'row' }, targets.length
? targets.map(p => H('button', { class: 'danger', 'data-label': 'Kill ' + p.name, 'data-ready': me.killReadyAt, onclick: () => act('kill', { targetId: p.id }) }, 'Kill ' + p.name))
: H('span', { class: 'muted' }, 'No one to target here.')));
acts.push(H('h4', {}, 'Sabotage'));
acts.push(H('div', { class: 'row' }, [['lights', 'Lights'], ['comms', 'Comms'], ['lockdown', 'Lockdown'], ['alarm', 'Alarm']].map(([t, l]) =>
H('button', { 'data-label': l, 'data-ready': me.sabotageReadyAt, 'data-hold': S.sabotage ? '1' : null, onclick: () => act('sabotage', { type: t }) }, l))));
}
if (me.alive && me.role === 'crew' && S.sabotage && me.location === S.sabotage.fixAt) {
acts.push(H('button', { class: 'primary', onclick: () => act('fix-sabotage') }, 'Fix it (needs 2 crew here)'));
}
if (me.alive && here.bodies.length) acts.push(H('button', { class: 'danger', onclick: () => act('report') }, 'Report ' + here.bodies[0].name + "'s body"));
if (me.alive && me.emergenciesLeft > 0) acts.push(H('button', { onclick: () => act('meeting') }, 'Emergency meeting'));
out.push(panel(H('h4', {}, 'Actions'), H('div', { class: 'list' }, acts.length ? acts : H('p', { class: 'muted' }, 'Nothing to do here.'))));
return out;
}
for (const [a, list] of Object.entries(S.links)) for (const b of list) if (a < b) {
ctx.beginPath(); ctx.moveTo(pos[a].x, pos[a].y); ctx.lineTo(pos[b].x, pos[b].y); ctx.stroke();
}
const players = {}, bodies = {};
if (S.spectator) {
S.spectator.players.forEach(p => { if (p.alive) players[p.location] = (players[p.location] || 0) + 1; });
S.spectator.bodies.forEach(b => { bodies[b.location] = (bodies[b.location] || 0) + 1; });
} else if (S.me && S.here) {
players[S.me.location] = S.here.players.length + 1;
if (S.here.bodies.length) bodies[S.me.location] = S.here.bodies.length;
}
const adj = (S.me && S.phase === 'mission') ? (S.links[S.me.location] || []) : [];
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
for (const l of locs) {
const p = pos[l.id], mine = S.me && S.me.location === l.id;
if (S.sabotage && S.sabotage.fixAt === l.id) {
// ---- Countdowns (server time, so phone clock differences do not matter) ----
setInterval(() => {
const now = Date.now() + skew;
document.querySelectorAll('[data-end]').forEach(e => {
const s = Math.max(0, Math.ceil((+e.dataset.end - now) / 1000));
e.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
});
document.querySelectorAll('[data-label]').forEach(b => {
const s = Math.ceil(((+b.dataset.ready || 0) - now) / 1000), wait = s > 0;
b.disabled = wait || b.dataset.hold === '1';
b.textContent = b.dataset.label + (wait ? ' (' + s + 's)' : '');
});
}, 300);
https://catinlens.com/game?code=ABC123
or
https://catinlens.com/game/room/ABC123
Link opens the game with the room code already filled. Players enter a name and join. No accounts required.
!1, !2, etc.)If the Impostor is still alive when the round ends, audience members who correctly predicted them get the reward. This is the hook for Twitch.
This is the complete game you described: a campus-based social deduction game that people play together, that works on your existing site, and that can involve a live audience. The backend files you have now implement the core of this.
Instead of cosmetic items that just look cool, identity should be an active tool in the psychological warfare.
To keep the cognitive load high and players distracted, tasks shouldn't just be solitary puzzles. They should force players into vulnerable positions.
When a body is found or a meeting is called, the web interface transforms into a dynamic debate floor designed to amplify peer pressure.
• Token based URL routing into a 20 player room within 2.0 seconds, no registration.
• SessionStorage caching so a refresh or closed tab rebinds to the same player.
• WebRTC proximity voice scaled by 2D distance.
• Variable reward tasks: server random draw, 95 percent base progress, 5 percent Critical Intel radar drop.
• Countdown sabotages (for example a power grid failure) that disable cameras for the rest of the match if a quota is missed.
• A Twitch Extension overlay with predictions, wagers and vanity tokens.
• Peer to peer WebRTC for 20 players needs 19 streams each. Use an SFU such as LiveKit with server side gating of who hears whom. Hardware accelerated WebRTC is not something you configure. Voice with strangers needs mute, report and moderation.
• Disabling cameras for the rest of the match snowballs. Use a temporary penalty.
Fixes applied to that page: 100dvh, sharp canvas on Retina, touch-action none, localStorage session, data driven poll, WebSocket stub gated by an empty SERVER_URL. A responsive pass then added: portrait layout (map on top, panels below, sidebar capped at 36dvh), a landscape phone layout (230px sidebar, tighter spacing), safe area padding for notches, and a ResizeObserver so rotation redraws the canvas.
• Hidden information leaked: everyone saw all locations and alive status, all bodies with names, and the feed announced kills.
• Win check counted only living crew's tasks, so a kill could hand the crew a win.
• Movement was an instant teleport to anywhere, cooldowns started at zero so kills were possible in the first second, any player could fix sabotage instantly from anywhere, tasks completed in one call.
• Minimum 3 players meant one kill ended the game. No restart, no leave, no host handover, no rejoin. Audience predictions were allowed after the winner was known and spammed the feed. A null payload crashed it, names were unsanitized, meetingCaller was never sent, lastActivity only updated on feed events, and cleanupRooms was never scheduled.
Identity: each player has a public id (used for votes and kills) and a secret token (sessionToken) sent only to its owner. Tokens use crypto.randomBytes; room codes use crypto.randomInt with an alphabet that avoids 0, O, 1 and I, and are checked for duplicates.
Settings: 20 players maximum, 5 minimum, 500 rooms, 500 audience members, names up to 20 characters, round 8 minutes, meeting 60 seconds, kill cooldown 30 seconds, sabotage cooldown 25 seconds, move cooldown 2 seconds, minimum task time 4 seconds, fix window 10 seconds, audience prediction window 90 seconds into a round, 1 emergency meeting per player, 3 tasks per crew member, room expiry after 30 minutes idle, feed of 20 lines. Impostors: 1 for up to 6 players, 2 for up to 12, otherwise 3.
Sabotages: Lights Out (45 s, vision), Comms Jammed (40 s, no tasks), Lockdown (30 s, no movement), Fire Alarm (50 s, forces a meeting when it expires). Each has a random fix location, and two living crew must press fix there within 10 seconds (or one if only one crew is alive). Starting a meeting clears any sabotage.
Actions: join, leave, start, restart, move (adjacent locations only), start-task, complete-task, kill, report, sabotage, fix-sabotage, meeting, vote, audience-prediction.
Rules: tasks need a start, a 4 second wait, then a finish, and any task action is blocked by Comms Jammed. Dead crew become ghosts who can still move and finish tasks, which keeps the task total fair. Kills are silent, leave a body, and cooldowns start when the round starts and reset after every meeting. Deaths become public only when a meeting starts. After a vote the tally goes in the feed, ties or skips eject nobody. Win checks: all impostors out (crew), all crew tasks done counting dead crew (crew), impostors equal or outnumber crew (impostors), or time runs out (impostors). Leaving in a lobby removes the player and passes host; leaving mid game removes that player's remaining tasks and counts as out. Restart returns everyone to the lobby.
About 20 KB, one file. Talks to https://api.catinlens.com. Screens: entry (name, room code, Join, Watch as audience, campus choice, Create room), lobby (invite link, player list, host Start button needing 5 players), and the game. The game shows a ring map of the campus's locations with links, your location highlighted, connected locations outlined, player counts and body markers, sabotage fix points ringed in red, and a click to move. Side panels: crew progress bar, sabotage alert with countdown, your role, tasks (Start, wait, Finish), move buttons, actions (kill, sabotage, fix, report, emergency meeting), meeting voting, results with Play again, and a feed. Ghosts and the audience see the full map. Audience members get Who wins? buttons. Session is stored in localStorage under campus-session so a refresh rejoins. Countdowns use server time. The fake audience poll was removed.
• The sus command: viewers type !sus Name during a meeting. Crowd version counts one vote per viewer and shows the top three on the overlay. Plain word sus is too noisy, so use a command.
• Twitch chat cannot show a live name picker. Options: numbered suspects on the overlay with !sus 2, partial names such as !sus ad with a short bot reply, a native Twitch Poll (2 to 5 choices, affiliate or partner only), or an Extension with buttons.
• Extension versus chat commands: no extension is needed. An Extension gives tap buttons but needs its own backend, HTTPS hosting and Twitch review, and it appears in Twitch's player, not inside the Larix video. Start with chat commands and a small bot (tmi.js is common), sending one reply every few seconds to respect rate limits.
• Audience view shows everyone's location, so a delay is needed if players might watch the stream.
• Real time 2D canvas game: you plus 6 bots picked from a roster (Tunde, Chiamaka, Bolu, Emeka, Aisha, Kelechi, Zainab), one of them the Fake. The player is the Fake about a third of rounds, guaranteed after three rounds without.
• World of 1800 by 1520 with 8 rooms: Main Library, Senate Building, Faculty of Arts, Jaja Hall, Keke Park, Sports Centre, Moremi Hall, Lagoon Front, joined by corridors. Walls block sight, furniture does not. Fog of war with a smaller view during a NEPA blackout.
• Pathfinding graph (for bots): library to senate to arts across the top; jaja to keke to sports in the middle; moremi to lagoon at the bottom; links from library to jaja, senate to keke, arts to sports, jaja to moremi, keke to lagoon, sports to lagoon, moremi to lagoon.
• Six task stations with minigames: Course Registration (Senate), Library ID Swipe, Print Assignment (Arts), Fix Hostel Gen (Jaja), Pump Football (Sports), Buy Jollof at Buka (Moremi, change calculation in naira).
• Sabotages: NEPA blackout (fixed at the main generator) and ASUU strike (45 second countdown, two petitions to sign at the Senate Building and Lagoon Front). Bots are assigned duties to fix them.
• Timing: 300 second round, 20 second discussion, 20 second vote. The Senate bell calls an emergency meeting (one use). Kill cooldowns around 20 to 26 seconds.
• Meetings: intro, discuss, vote, tally, ejection animation. Quick chat lines in Nigerian Pidgin: accuse, defend, where were you, alibi, skip (6 messages max). Bots track witnesses, suspicion, alibis and contradictions and vote on that.
• Extras: XP levels with titles (JJC, Fresher, Stalite, Chief Detective, Area Father of Akoka), colors, skins, hats, generated sound (no audio files), stats screen, streamer mode (larger text), save key wbtf-unilag-v1 in localStorage.
• Build the transparent stream overlay page for Larix (large text, transparent background, same audience view).
• Build the Twitch chat bot and the audience-sus bridge if option A is chosen.
• Decide whether to define real UI and Babcock layouts for the game.
• Add meeting chat (quick chat is the recommended pattern) if the multiplayer engine continues.
<label>Your name <input id="name" maxlength="20" autocomplete="nickname"></label>
<label>Room code <input id="code" maxlength="8" autocapitalize="characters" autocomplete="off"></label>
<div class="row"><button class="primary" id="b-join">Join room</button><button id="b-watch">Watch</button></div><label>Campus for a new room
<select id="campus"><option value="unilag">UNILAG</option><option value="oau">OAU</option><option value="ui">UI</option><option value="babcock">Babcock</option></select>
</label>
<button id="b-create">Create room</button>
</div>const store = {
get(k) { try { return JSON.parse(localStorage.getItem(k)); } catch (e) { return null; } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} },
del(k) { try { localStorage.removeItem(k); } catch (e) {} },
};
// Tiny element builder. Text is always added as text, never as HTML.
function H(tag, attrs, ...kids) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k === 'class') e.className = v;
else if (k.startsWith('on')) e.addEventListener(k.slice(2), v);
else if (k.startsWith('data-')) e.setAttribute(k, v);
else e[k] = v;
}
for (const c of kids.flat()) if (c != null && c !== false) e.append(c.nodeType ? c : String(c));
return e;
}
let sess = null, S = null, skew = 0, lastKey = '', busy = false, msgT = 0, nodes = [];
function setMsg(t) {
$('msg').textContent = t || '';
clearTimeout(msgT);
if (t) msgT = setTimeout(() => { $('msg').textContent = ''; }, 4000);
}
async function api(path, body) {
try {
const r = await fetch(API + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({ error: 'Bad response from server' }));
return { status: r.status, ...j };
} catch (e) { return { status: 0, error: 'Cannot reach the server' }; }
}
// ---- State ----
function apply(st) {
skew = st.now - Date.now();
const { now, status, ...rest } = st;
S = st;
const key = JSON.stringify(rest);
if (key !== lastKey) { lastKey = key; render(); }
}
async function poll() {
if (!sess || busy) return;
busy = true;
const r = await api('/api/rooms/state', { code: sess.code, sessionToken: sess.token });
busy = false;
if (!sess) return;
if (r.status === 404) return leaveLocal('That room ended, or your session expired.');
if (r.status === 0) return setMsg('Connection lost. Retrying...');
if (r.phase) apply(r); else if (r.error) setMsg(r.error);
}
function leaveLocal(note) {
sess = null; S = null; lastKey = '';
store.del('campus-session');
history.replaceState(null, '', location.pathname);
render();
if (note) setMsg(note);
}
async function enter(kind) {
const name = $('name').value.trim(), code = $('code').value.trim().toUpperCase();
if (kind !== 'watch' && !name) return setMsg('Enter your name first.');
if (kind !== 'create' && !code) return setMsg('Enter a room code.');
let r;
if (kind === 'create') r = await api('/api/rooms/create', { displayName: name, campus: $('campus').value });
else if (kind === 'join') r = await api('/api/rooms/join', { code, displayName: name });
else r = await api('/api/rooms/audience', { code, displayName: name || undefined });
if (r.error) return setMsg(r.error);
sess = { code: r.code || code, token: r.sessionToken || null, audienceId: r.audienceId || null };
store.set('campus-session', sess);
history.replaceState(null, '', '?code=' + sess.code);
apply(r.room);
}
async function act(action, payload) {
const r = await api('/api/rooms/action', { code: sess.code, sessionToken: sess.token, action, payload });
if (r.error) setMsg(r.error);
else if (r.left) leaveLocal();
else if (r.room) apply(r.room);
}
async function predict(p) {
const r = await api('/api/rooms/action', { code: sess.code, action: 'audience-prediction', payload: { audienceId: sess.audienceId, prediction: p } });
setMsg(r.error || 'Prediction saved');
}
const locName = id => S.locations.find(l => l.id === id)?.name || id;
const feedPanel = () => panel(H('h4', {}, 'Feed'), H('div', { class: 'list' }, S.feed.slice(0, 8).map(f => H('p', { class: 'muted' }, f))));
H('button', { onclick: () => predict('crew') }, 'Crew'),
H('button', { onclick: () => predict('impostor') }, 'Impostors')) : null;
function render() {
const lobby = !!S && S.phase === 'lobby';
$('entry').hidden = !!(S || sess);
$('lobby').hidden = !lobby;
$('game').hidden = !(S && !lobby);
if (!S) return;
if (lobby) renderLobby(); else renderGame();
}
function renderLobby() {
const me = S.me, enough = S.roster.length >= 5;
$('lobby').replaceChildren(
H('h3', {}, 'Room ' + S.code),
H('p', { class: 'muted' }, 'Invite link: ' + location.origin + location.pathname + '?code=' + S.code),
H('h4', {}, 'Players (' + S.roster.length + ')'),
H('div', { class: 'list' }, S.roster.map(p => H('div', {}, p.name + (p.isHost ? ' (host)' : '') + (me && p.id === me.id ? ' (you)' : '')))),
me && me.isHost ? H('button', { class: 'primary', disabled: !enough, onclick: () => act('start') }, 'Start game') : null,
me && me.isHost && !enough ? H('p', { class: 'muted' }, 'You need at least 5 players.') : null,
me && !me.isHost ? H('p', { class: 'muted' }, 'Waiting for the host to start.') : null,
!me ? H('p', { class: 'muted' }, 'You are watching this room.') : null,
!me ? predictRow() : null,
leaveBtn());
}
function renderGame() {
$('room-code').textContent = 'ROOM ' + S.code;
const t = $('timer');
const end = S.phase === 'meeting' ? S.meeting && S.meeting.ends : S.phase === 'mission' ? S.timer : null;
if (end) t.dataset.end = end;
else { delete t.dataset.end; t.textContent = S.phase === 'results' ? 'Over' : ''; }
drawMap();
const main = S.phase === 'results' ? [resultsPanel()] : S.phase === 'meeting' ? [meetingPanel()] : missionPanels();
$('side').replaceChildren(...main, feedPanel(), leaveBtn());
}
function resultsPanel() {
return panel(
H('h3', {}, S.winner === 'crew' ? 'Crew wins' : 'Impostors win'),
H('div', { class: 'list' }, S.roster.map(p => H('div', { class: 'task' },
H('span', {}, p.name), H('span', { class: 'muted' }, (p.role || '') + (p.alive ? '' : ' (out)'))))),
S.me && S.me.isHost
? H('button', { class: 'primary', onclick: () => act('restart') }, 'Play again')
: H('p', { class: 'muted' }, 'Waiting for the host to restart.'));
}
function meetingPanel() {
const me = S.me, voted = S.meeting.voted;
const canVote = !!me && me.alive && !voted.includes(me.id);
return panel(
H('h3', {}, S.meeting.caller + ' called a meeting'),
H('p', { class: 'muted' }, me && me.alive ? (canVote ? 'Who is the impostor?' : 'Vote cast. Waiting for the others.') : 'Only living players vote.'),
H('div', { class: 'list' }, S.roster.filter(p => p.alive).map(p => H('div', { class: 'task' },
H('span', {}, p.name + (voted.includes(p.id) ? ' (voted)' : '')),
me && p.id !== me.id ? H('button', { disabled: !canVote, onclick: () => act('vote', { targetId: p.id }) }, 'Vote') : null))),
canVote ? H('button', { onclick: () => act('vote', { targetId: 'skip' }) }, 'Skip vote') : null);
}
function missionPanels() {
const me = S.me, tp = S.taskProgress, out = [];
const fill = H('div');
fill.style.width = (tp.total ? Math.round(100 * tp.done / tp.total) : 0) + '%';
out.push(panel(H('h4', {}, 'Crew tasks ' + tp.done + '/' + tp.total), H('div', { class: 'bar' }, fill)));
if (S.sabotage) out.push(H('div', { class: 'panel alert' },
H('h3', {}, S.sabotage.label),
H('p', { class: 'muted' }, 'Fix point: ' + S.sabotage.fixAtName + '. Ends in ', H('span', { class: 'timer', 'data-end': S.sabotage.ends }))));
if (!me) {
out.push(panel(H('h3', {}, 'Watching'), H('p', { class: 'muted' }, 'You can see every player on the map.'), predictRow()));
return out;
}
out.push(panel(
H('h3', {}, (me.role === 'impostor' ? 'You are an Impostor' : 'You are Crew') + (me.alive ? '' : ' (out)')),
H('p', { class: 'muted' }, 'You are at ' + locName(me.location)),
me.partners.length ? H('p', { class: 'muted' }, 'Other impostors: ' + me.partners.map(p => p.name).join(', ')) : null,
!me.alive ? H('p', { class: 'muted' }, me.role === 'crew' ? 'You are a ghost. Finishing tasks still helps the crew.' : 'You are out of the round.') : null));
const hold = e => (S.sabotage && S.sabotage.effect === e) ? '1' : null;
if (me.role === 'crew') {
const taskRow = t => {
const at = me.location === t.location, active = me.activeTask && me.activeTask.id === t.id;
const btn = !at ? H('button', { disabled: true }, 'Go there')
: active ? H('button', { class: 'primary', 'data-label': 'Finish', 'data-ready': me.activeTask.readyAt, 'data-hold': hold('no-tasks'), onclick: () => act('complete-task', { taskId: t.id }) }, 'Finish')
: H('button', { 'data-label': 'Start', 'data-hold': hold('no-tasks'), onclick: () => act('start-task', { taskId: t.id }) }, 'Start');
return H('div', { class: 'task' }, H('span', {}, t.label), btn);
};
out.push(panel(H('h4', {}, 'Your tasks'), H('div', { class: 'list' }, me.tasks.length ? me.tasks.map(taskRow) : H('p', { class: 'muted' }, 'All done.'))));
}
out.push(panel(H('h4', {}, 'Move to'), H('div', { class: 'row' }, (S.links[me.location] || []).map(id =>
H('button', { 'data-label': locName(id), 'data-ready': me.moveReadyAt, 'data-hold': hold('no-move'), onclick: () => act('move', { location: id }) }, locName(id))))));
acts.push(H('div', { class: 'row' }, targets.length
? targets.map(p => H('button', { class: 'danger', 'data-label': 'Kill ' + p.name, 'data-ready': me.killReadyAt, onclick: () => act('kill', { targetId: p.id }) }, 'Kill ' + p.name))
: H('span', { class: 'muted' }, 'No one to target here.')));
acts.push(H('h4', {}, 'Sabotage'));
acts.push(H('div', { class: 'row' }, [['lights', 'Lights'], ['comms', 'Comms'], ['lockdown', 'Lockdown'], ['alarm', 'Alarm']].map(([t, l]) =>
H('button', { 'data-label': l, 'data-ready': me.sabotageReadyAt, 'data-hold': S.sabotage ? '1' : null, onclick: () => act('sabotage', { type: t }) }, l))));
}
if (me.alive && me.role === 'crew' && S.sabotage && me.location === S.sabotage.fixAt) {
acts.push(H('button', { class: 'primary', onclick: () => act('fix-sabotage') }, 'Fix it (needs 2 crew here)'));
}
if (me.alive && here.bodies.length) acts.push(H('button', { class: 'danger', onclick: () => act('report') }, 'Report ' + here.bodies[0].name + "'s body"));
if (me.alive && me.emergenciesLeft > 0) acts.push(H('button', { onclick: () => act('meeting') }, 'Emergency meeting'));
out.push(panel(H('h4', {}, 'Actions'), H('div', { class: 'list' }, acts.length ? acts : H('p', { class: 'muted' }, 'Nothing to do here.'))));
return out;
}
const wrap = $('canvas-wrap'), canvas = $('gameCanvas'), ctx = canvas.getContext('2d');
function drawMap() {
const dpr = window.devicePixelRatio || 1, w = wrap.clientWidth, ht = wrap.clientHeight;
if (!w || !ht) return;
canvas.width = Math.round(w * dpr); canvas.height = Math.round(ht * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#0d0d0d'; ctx.fillRect(0, 0, w, ht);
nodes = [];
if (!S || !S.locations) return;
const locs = S.locations, n = locs.length, cx = w / 2, cy = ht / 2, r = Math.max(40, Math.min(w, ht) / 2 - 52), pos = {};
locs.forEach((l, i) => {
const a = -Math.PI / 2 + 2 * Math.PI * i / n;
pos[l.id] = { id: l.id, x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) };
nodes.push(pos[l.id]);
});
ctx.strokeStyle = COL.line; ctx.lineWidth = 1.5;
for (const [a, list] of Object.entries(S.links)) for (const b of list) if (a < b) {
ctx.beginPath(); ctx.moveTo(pos[a].x, pos[a].y); ctx.lineTo(pos[b].x, pos[b].y); ctx.stroke();
}
const players = {}, bodies = {};
if (S.spectator) {
S.spectator.players.forEach(p => { if (p.alive) players[p.location] = (players[p.location] || 0) + 1; });
S.spectator.bodies.forEach(b => { bodies[b.location] = (bodies[b.location] || 0) + 1; });
} else if (S.me && S.here) {
players[S.me.location] = S.here.players.length + 1;
if (S.here.bodies.length) bodies[S.me.location] = S.here.bodies.length;
}
const adj = (S.me && S.phase === 'mission') ? (S.links[S.me.location] || []) : [];
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
for (const l of locs) {
const p = pos[l.id], mine = S.me && S.me.location === l.id;
if (S.sabotage && S.sabotage.fixAt === l.id) {
// ---- Countdowns (server time, so phone clock differences do not matter) ----
setInterval(() => {
const now = Date.now() + skew;
document.querySelectorAll('[data-end]').forEach(e => {
const s = Math.max(0, Math.ceil((+e.dataset.end - now) / 1000));
e.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
});
document.querySelectorAll('[data-label]').forEach(b => {
const s = Math.ceil(((+b.dataset.ready || 0) - now) / 1000), wait = s > 0;
b.disabled = wait || b.dataset.hold === '1';
b.textContent = b.dataset.label + (wait ? ' (' + s + 's)' : '');
});
// ---- Start ----
const urlCode = (new URLSearchParams(location.search).get('code') || '').toUpperCase();
$('code').value = urlCode;
sess = store.get('campus-session');
if (sess && urlCode && urlCode !== sess.code) sess = null;
$('b-create').onclick = () => enter('create');
$('b-join').onclick = () => enter('join');
| Game Loop Stage | Core Game Mechanic | Psychological Trigger | Addictive Hook |
|---|---|---|---|
| Lobby Matchmaking | Passport Profile Display | Pre-existing Bias & Paranoia | Players work to curate their "Legendary Deceiver" or "Trusted Ally" profile. |
| Live Round Phase | Split-Focus Tasks & Sabotage | High Cognitive Load & Vulnerability | The thrill of pulling off a execution while the victim is typing an expense report. |
| Tribunal Phase | Spotlight Intimidation & Emotes | Social Dynamics & Threat Defense | The dopamine rush of successfully shifting the blame to an innocent bystander. |
• Original request: add games to a Larix overlay, the way OBS has a browser source, so an audience can play along. The streamer plays on another device. The idea is an Among Us style game.
• The overlay is display only. It is drawn into the video, nobody can tap it, and sounds playing inside it are not mixed into the stream.
• Token based URL routing into a 20 player room within 2.0 seconds, no registration.
• SessionStorage caching so a refresh or closed tab rebinds to the same player.
• WebRTC proximity voice scaled by 2D distance.
• Variable reward tasks: server random draw, 95 percent base progress, 5 percent Critical Intel radar drop.
• Countdown sabotages (for example a power grid failure) that disable cameras for the rest of the match if a quota is missed.
• Crowd voted sabotage through Twitch chat with a 30 second window.
• A subscriber priority queue that pushes join tokens to chat.
• Disabling cameras for the rest of the match snowballs. Use a temporary penalty.
Fixes applied to that page: 100dvh, sharp canvas on Retina, touch-action none, localStorage session, data driven poll, WebSocket stub gated by an empty SERVER_URL. A responsive pass then added: portrait layout (map on top, panels below, sidebar capped at 36dvh), a landscape phone layout (230px sidebar, tighter spacing), safe area padding for notches, and a ResizeObserver so rotation redraws the canvas.
• Win check counted only living crew's tasks, so a kill could hand the crew a win.
• Movement was an instant teleport to anywhere, cooldowns started at zero so kills were possible in the first second, any player could fix sabotage instantly from anywhere, tasks completed in one call.
• Minimum 3 players meant one kill ended the game. No restart, no leave, no host handover, no rejoin. Audience predictions were allowed after the winner was known and spammed the feed. A null payload crashed it, names were unsanitized, meetingCaller was never sent, lastActivity only updated on feed events, and cleanupRooms was never scheduled.
Identity: each player has a public id (used for votes and kills) and a secret token (sessionToken) sent only to its owner. Tokens use crypto.randomBytes; room codes use crypto.randomInt with an alphabet that avoids 0, O, 1 and I, and are checked for duplicates.
Settings: 20 players maximum, 5 minimum, 500 rooms, 500 audience members, names up to 20 characters, round 8 minutes, meeting 60 seconds, kill cooldown 30 seconds, sabotage cooldown 25 seconds, move cooldown 2 seconds, minimum task time 4 seconds, fix window 10 seconds, audience prediction window 90 seconds into a round, 1 emergency meeting per player, 3 tasks per crew member, room expiry after 30 minutes idle, feed of 20 lines. Impostors: 1 for up to 6 players, 2 for up to 12, otherwise 3.
Sabotages: Lights Out (45 s, vision), Comms Jammed (40 s, no tasks), Lockdown (30 s, no movement), Fire Alarm (50 s, forces a meeting when it expires). Each has a random fix location, and two living crew must press fix there within 10 seconds (or one if only one crew is alive). Starting a meeting clears any sabotage.
Actions: join, leave, start, restart, move (adjacent locations only), start-task, complete-task, kill, report, sabotage, fix-sabotage, meeting, vote, audience-prediction.
Rules: tasks need a start, a 4 second wait, then a finish, and any task action is blocked by Comms Jammed. Dead crew become ghosts who can still move and finish tasks, which keeps the task total fair. Kills are silent, leave a body, and cooldowns start when the round starts and reset after every meeting. Deaths become public only when a meeting starts. After a vote the tally goes in the feed, ties or skips eject nobody. Win checks: all impostors out (crew), all crew tasks done counting dead crew (crew), impostors equal or outnumber crew (impostors), or time runs out (impostors). Leaving in a lobby removes the player and passes host; leaving mid game removes that player's remaining tasks and counts as out. Restart returns everyone to the lobby.
What each viewer sees: roster (names, alive only after a meeting reveals deaths), me (role, tasks, cooldown times, fellow impostors), here (only living players and bodies in the viewer's own location), spectator (full map for audience, ghosts and the results screen, no roles until results), task progress, meeting info, sabotage info, feed, timer, winner, and now (server time, added so countdowns are correct despite phone clock differences).
About 20 KB, one file. Talks to https://api.catinlens.com. Screens: entry (name, room code, Join, Watch as audience, campus choice, Create room), lobby (invite link, player list, host Start button needing 5 players), and the game. The game shows a ring map of the campus's locations with links, your location highlighted, connected locations outlined, player counts and body markers, sabotage fix points ringed in red, and a click to move. Side panels: crew progress bar, sabotage alert with countdown, your role, tasks (Start, wait, Finish), move buttons, actions (kill, sabotage, fix, report, emergency meeting), meeting voting, results with Play again, and a feed. Ghosts and the audience see the full map. Audience members get Who wins? buttons. Session is stored in localStorage under campus-session so a refresh rejoins. Countdowns use server time. The fake audience poll was removed.
• The sus command: viewers type !sus Name during a meeting. Crowd version counts one vote per viewer and shows the top three on the overlay. Plain word sus is too noisy, so use a command.
• Twitch chat cannot show a live name picker. Options: numbered suspects on the overlay with !sus 2, partial names such as !sus ad with a short bot reply, a native Twitch Poll (2 to 5 choices, affiliate or partner only), or an Extension with buttons.
• Extension versus chat commands: no extension is needed. An Extension gives tap buttons but needs its own backend, HTTPS hosting and Twitch review, and it appears in Twitch's player, not inside the Larix video. Start with chat commands and a small bot (tmi.js is common), sending one reply every few seconds to respect rate limits.
• Audience view shows everyone's location, so a delay is needed if players might watch the stream.
The room engine has no chat, only a timer and votes. Proposed options: text chat during meetings (a say action with limits), voice per location through an SFU, or an accuse button. The user's own game already has Pidgin quick chat (section 12), which is the safer pattern for multiplayer and for Twitch.
• World of 1800 by 1520 with 8 rooms: Main Library, Senate Building, Faculty of Arts, Jaja Hall, Keke Park, Sports Centre, Moremi Hall, Lagoon Front, joined by corridors. Walls block sight, furniture does not. Fog of war with a smaller view during a NEPA blackout.
• Pathfinding graph (for bots): library to senate to arts across the top; jaja to keke to sports in the middle; moremi to lagoon at the bottom; links from library to jaja, senate to keke, arts to sports, jaja to moremi, keke to lagoon, sports to lagoon, moremi to lagoon.
• Six task stations with minigames: Course Registration (Senate), Library ID Swipe, Print Assignment (Arts), Fix Hostel Gen (Jaja), Pump Football (Sports), Buy Jollof at Buka (Moremi, change calculation in naira).
• Sabotages: NEPA blackout (fixed at the main generator) and ASUU strike (45 second countdown, two petitions to sign at the Senate Building and Lagoon Front). Bots are assigned duties to fix them.
• Timing: 300 second round, 20 second discussion, 20 second vote. The Senate bell calls an emergency meeting (one use). Kill cooldowns around 20 to 26 seconds.
• Meetings: intro, discuss, vote, tally, ejection animation. Quick chat lines in Nigerian Pidgin: accuse, defend, where were you, alibi, skip (6 messages max). Bots track witnesses, suspicion, alibis and contradictions and vote on that.
• Extras: XP levels with titles (JJC, Fresher, Stalite, Chief Detective, Area Father of Akoka), colors, skins, hats, generated sound (no audio files), stats screen, streamer mode (larger text), save key wbtf-unilag-v1 in localStorage.
• Build the Twitch chat bot and the audience-sus bridge if option A is chosen.
• Decide whether to define real UI and Babcock layouts for the game.
• Add meeting chat (quick chat is the recommended pattern) if the multiplayer engine continues.
H('div', { class: 'list' }, S.roster.map(p => H('div', {}, p.name + (p.isHost ? ' (host)' : '') + (me && p.id === me.id ? ' (you)' : '')))),
function renderLobby() {
const me = S.me, enough = S.roster.length >= 5;
$('lobby').replaceChildren(
H('h3', {}, 'Room ' + S.code),
H('p', { class: 'muted' }, 'Invite link: ' + location.origin + location.pathname + '?code=' + S.code),
H('h4', {}, 'Players (' + S.roster.length + ')'),
me && me.isHost ? H('button', { class: 'primary', disabled: !enough, onclick: () => act('start') }, 'Start game') : null,
me && me.isHost && !enough ? H('p', { class: 'muted' }, 'You need at least 5 players.') : null,
me && !me.isHost ? H('p', { class: 'muted' }, 'Waiting for the host to start.') : null,
!me ? H('p', { class: 'muted' }, 'You are watching this room.') : null,
!me ? predictRow() : null,
leaveBtn());
canvas.addEventListener('click', e => {
if (!S || !S.me || S.phase !== 'mission') return;
const b = canvas.getBoundingClientRect(), x = e.clientX - b.left, y = e.clientY - b.top;
const hit = nodes.find(n => Math.hypot(n.x - x, n.y - y) < 28);
if (hit && (S.links[S.me.location] || []).includes(hit.id)) act('move', { location: hit.id });
});
// ---- Start ----
const urlCode = (new URLSearchParams(location.search).get('code') || '').toUpperCase();
$('code').value = urlCode;
sess = store.get('campus-session');
if (sess && urlCode && urlCode !== sess.code) sess = null;
$('b-create').onclick = () => enter('create');
$('b-join').onclick = () => enter('join');
$('b-watch').onclick = () => enter('watch');
render();
poll();
setInterval(poll, 1000);
})();
</script> </body> </html>• Crowd voted sabotage through Twitch chat with a 30 second window.
• A subscriber priority queue that pushes join tokens to chat.
The room engine has no chat, only a timer and votes. Proposed options: text chat during meetings (a say action with limits), voice per location through an SFU, or an accuse button. The user's own game already has Pidgin quick chat (section 12), which is the safer pattern for multiplayer and for Twitch.
Three options were offered: (A) keep the single player game and stream it, adding a chat bridge so Twitch chat influences meetings or sabotage, plus a Larix overlay tally; (B) real multiplayer of the user's game with a server simulating movement; (C) reskin the location based engine with the game's rooms and quick chat. Recommendation: A first, since the game is finished and the audience hook is where the stream gets its fun. No choice has been made yet.
https://catinlens.com/game?code=ABC123
or
https://catinlens.com/game/room/ABC123
Link opens the game with the room code already filled. Players enter a name and join. No accounts required.
!1, !2, etc.)const leaveBtn = () => H('button', { onclick: () => {
if (!confirm('Leave this game?')) return;
if (sess.token) act('leave'); else leaveLocal();
} }, 'Leave');
const predictRow = () => sess.audienceId ? H('div', { class: 'row' },
H('span', { class: 'muted' }, 'Who wins?'),
canvas.addEventListener('click', e => {
if (!S || !S.me || S.phase !== 'mission') return;
const b = canvas.getBoundingClientRect(), x = e.clientX - b.left, y = e.clientY - b.top;
const hit = nodes.find(n => Math.hypot(n.x - x, n.y - y) < 28);
if (hit && (S.links[S.me.location] || []).includes(hit.id)) act('move', { location: hit.id });
});
$('b-watch').onclick = () => enter('watch');
render();
poll();
setInterval(poll, 1000);
})();
</script>• Build the transparent stream overlay page for Larix (large text, transparent background, same audience view).
[Design proposal; unspecified choices are defaults.]
Muse: Jony Ive. Headline: “CAMP-US — a black instrument panel, not a game skin.”
CAMP-US uses a clean, serious, Apple-like visual system for multiplayer social deduction on Nigerian university campuses, catinlens.com, and Larix viewing. Use #0A0A0A for the page ground, #111111 for panels, #0D0D0D for recessed map and form wells, #F2F2F2 for primary text, #8A8A8A for muted metadata, #222222 for hairlines, #0071E3 for the sole primary accent, and #FF3B30 for danger. Blue marks the player, reachable locations, task progress, and primary actions. Red marks timers, sabotage borders, body markers, and kill actions. Alert surfaces use near-black red-tinted #1A0D0C or #2A1210 with a red border. Never communicate meaning through colour alone: retain labels and symbols such as “you,” “here,” “Sabotage,” “Fix point,” and “x.”
Headings and the wordmark use Inter Tight; body copy uses Inter, followed by -apple-system, BlinkMacSystemFont, “Segoe UI”, system-ui, sans-serif as fallback. Body text is 15px/1.45. Use the 12, 13, 15, 19, 24, 30, and 38px scale. Micro-labels are 12px uppercase with 0.08em tracking; panel headings are 14px. Room codes, timers, player counts, and task totals use tabular numerals. The CAMP-US wordmark is Inter Tight 700, 13px, uppercase, with 0.08em tracking. Room codes use Inter Tight 700 at 24–38px with responsive clamping; timers use Inter 600 at 15px with 0.04em tracking.
Use continuous-curve geometry: 10px panels, 8px fields and buttons, 3px progress caps, 20px map nodes, and a 27px sabotage ring. Use 1px #222222 borders except for focus outlines and the sabotage ring. No shadows, gradients, glows, blur, glassmorphism, neon, AI imagery, gacha effects, emoji, decorative icon sets, characters, or stock imagery. Depth comes only from the luminance steps between #0A0A0A, #111111, and #0D0D0D.
The shell is 100dvh with safe-area padding and 16px gutters. Desktop uses a 1fr / 320px grid with a flexible main column and scrollable sidebar separated by 12px gaps. The header places CAMP-US left, the room code centrally, and the timer right; the canvas well fills the remaining height. At max-width 700px or max-aspect-ratio 1/1, use one column with the map above and the sidebar capped at 38dvh. In landscape screens under 520px high, use a 1fr / 250px grid with 8px 12px panel padding. Entry and lobby use a centred 400px column with an 8vh top margin.
The map is the sole crafted visual object: a radial campus instrument face on #0D0D0D, with 1.5px #222222 link paths, a #0071E3 current-location node, #0071E3 rings for reachable nodes, and other nodes filled #161616 with #333333 rings. Player counts are 600 13px #F2F2F2 numerals; bodies are red “x” markers; location labels are 12px #8A8A8A and truncate at 16 characters. Use device-pixel-ratio-aware sizing, touch-action: none, and ResizeObserver redraws. Necessary defaults: where the source does not specify an additional visual token, preserve this direction’s restrained, material-honest treatment rather than adding decoration.
:root {
--bg: #0a0a0a; --surface: #111111; --text: #f2f2f2; --muted: #8a8a8a;
--line: #222222; --accent: #0071e3; --danger: #ff3b30;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
[hidden] { display: none !important; }
html, body { height: 100%; background: var(--bg); color: var(--text); font: 15px/1.45 var(--sans); }
.game {
display: grid; grid-template-columns: 1fr 320px; gap: 16px;
height: 100vh; height: 100dvh;
padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right))
max(16px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
}
.main { display: flex; flex-direction: column; gap: 12px; min-width: 0; min-height: 0; }
.sidebar { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; min-height: 0; }
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
.panel.alert { border-color: var(--danger); }
.header { display: flex; justify-content: space-between; align-items: center; font-size: 13px; color: var(--muted); }
.header strong { color: var(--text); font-weight: 600; }
.canvas-wrap { flex: 1; background: #0d0d0d; border: 1px solid var(--line); border-radius: 10px; min-height: 0; overflow: hidden; }
canvas { width: 100%; height: 100%; display: block; touch-action: none; }
h3 { font-size: 14px; font-weight: 600; margin-bottom: 8px; }
h4 { font-size: 12px; font-weight: 600; letter-spacing: 0.02em; margin-bottom: 6px; color: var(--muted); }
.muted { color: var(--muted); font-size: 13px; }
.timer { color: var(--danger); font-variant-numeric: tabular-nums; }
.center { max-width: 400px; margin: 8vh auto; display: flex; flex-direction: column; gap: 10px; }
label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted); }
input, select, button {
font: inherit; color: var(--text); background: #0d0d0d; border: 1px solid var(--line);
border-radius: 8px; padding: 8px 12px;
}
button { background: #1a1a1a; cursor: pointer; }
button:hover:not(:disabled) { border-color: #444; }
button:disabled { opacity: 0.4; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); }
button.danger { background: #3a1512; border-color: var(--danger); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; }
.list { display: flex; flex-direction: column; gap: 8px; }
.task { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: 13px; }
.bar { height: 6px; background: #1c1c1c; border-radius: 3px; overflow: hidden; }
.bar > div { height: 100%; background: var(--accent); }
#msg { position: fixed; top: 12px; left: 50%; transform: translateX(-50%); z-index: 10; max-width: 90vw;
background: #2a1210; border: 1px solid var(--danger); border-radius: 8px; padding: 8px 14px; font-size: 13px; }
#msg:empty { display: none; }
ctx.strokeStyle = COL.danger; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(p.x, p.y, 27, 0, 7); ctx.stroke();
}
ctx.beginPath(); ctx.arc(p.x, p.y, 20, 0, 7);
ctx.fillStyle = mine ? COL.accent : '#161616'; ctx.fill();
ctx.strokeStyle = adj.includes(l.id) ? COL.accent : '#333'; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = COL.text; ctx.font = '600 13px system-ui, sans-serif';
if (players[l.id]) ctx.fillText(players[l.id], p.x, p.y);
if (bodies[l.id]) { ctx.fillStyle = COL.danger; ctx.fillText('x', p.x + 23, p.y - 22); }
ctx.fillStyle = COL.muted; ctx.font = '12px system-ui, sans-serif';
ctx.fillText(l.name.length > 16 ? l.name.slice(0, 15) + '...' : l.name, p.x, p.y + 34);
}
}
First mockup (glassmorphic, neon palette): opaque background would cover the camera in an overlay, blur and infinite animations are heavy on a phone that is also encoding, the canvas was stretched by CSS, the poll bars added to 90 percent, color alone carried meaning, and an emoji was lost in the header.
Second mockup (the user's cleaner CAMP-US page): lighter and better, with a mobile breakpoint, tabular timer digits and safe text insertion. Problems: the API pointed at catinlens.com but the user's domain spelling varied, background still opaque so it cannot be the Larix overlay, 13px text too small for stream video, no devicePixelRatio handling, 100vh cut off on iPhone Safari, SessionStorage restore, hardcoded poll values and no connection code.
• UNILAG: a labelled map including the Lagoon, Main Library, Senate, Jaja Hall, Moremi Hall, Sports Centre and others. The game's layout is stylized, not geographic.
• UI (Ibadan): SUB, S. Bello Hall, Inde Hall, Awo Hall, Agric, Vet Med, UI Zoo, Awba Dam and the Zoo and archive areas.
• Babcock tour map with dashed routes: Main Gate, Pioneer SDA Church, Shopping Mall, Registry, Senate, SAT, BBS, Cafeteria, Amphitheatre, Academic Building, Stadium, and hostels such as Havillah Gold, Winslow, Emerald, White Hall, Crystal, Platinum and Diamond. Labels as read from the image.
:root {
--bg: #0a0a0a; --surface: #111111; --text: #f2f2f2; --muted: #8a8a8a;
--line: #222222; --accent: #0071e3; --danger: #ff3b30;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
html, body { height: 100%; background: var(--bg); color: var(--text); font: 15px/1.45 var(--sans); }
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
.panel.alert { border-color: var(--danger); }
.header { display: flex; justify-content: space-between; align-items: center; font-size: 13px; color: var(--muted); }
.header strong { color: var(--text); font-weight: 600; }
.canvas-wrap { flex: 1; background: #0d0d0d; border: 1px solid var(--line); border-radius: 10px; min-height: 0; overflow: hidden; }
h3 { font-size: 14px; font-weight: 600; margin-bottom: 8px; }
h4 { font-size: 12px; font-weight: 600; letter-spacing: 0.02em; margin-bottom: 6px; color: var(--muted); }
.muted { color: var(--muted); font-size: 13px; }
.timer { color: var(--danger); font-variant-numeric: tabular-nums; }
.center { max-width: 400px; margin: 8vh auto; display: flex; flex-direction: column; gap: 10px; }
label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted); }
input, select, button {
font: inherit; color: var(--text); background: #0d0d0d; border: 1px solid var(--line);
border-radius: 8px; padding: 8px 12px;
}
button { background: #1a1a1a; cursor: pointer; }
button:hover:not(:disabled) { border-color: #444; }
button:disabled { opacity: 0.4; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); }
button.danger { background: #3a1512; border-color: var(--danger); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.row { display: flex; gap: 8px; flex-wrap: wrap; }
.list { display: flex; flex-direction: column; gap: 8px; }
.task { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: 13px; }
.bar { height: 6px; background: #1c1c1c; border-radius: 3px; overflow: hidden; }
.bar > div { height: 100%; background: var(--accent); }
#msg { position: fixed; top: 12px; left: 50%; transform: translateX(-50%); z-index: 10; max-width: 90vw;
background: #2a1210; border: 1px solid var(--danger); border-radius: 8px; padding: 8px 14px; font-size: 13px; }
#msg:empty { display: none; }
// ---- Rendering ----
const panel = (...k) => H('div', { class: 'panel' }, ...k);
ctx.strokeStyle = COL.danger; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(p.x, p.y, 27, 0, 7); ctx.stroke();
}
ctx.beginPath(); ctx.arc(p.x, p.y, 20, 0, 7);
ctx.fillStyle = mine ? COL.accent : '#161616'; ctx.fill();
ctx.strokeStyle = adj.includes(l.id) ? COL.accent : '#333'; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = COL.text; ctx.font = '600 13px system-ui, sans-serif';
if (players[l.id]) ctx.fillText(players[l.id], p.x, p.y);
if (bodies[l.id]) { ctx.fillStyle = COL.danger; ctx.fillText('x', p.x + 23, p.y - 22); }
ctx.fillStyle = COL.muted; ctx.font = '12px system-ui, sans-serif';
ctx.fillText(l.name.length > 16 ? l.name.slice(0, 15) + '...' : l.name, p.x, p.y + 34);
}
}
First mockup (glassmorphic, neon palette): opaque background would cover the camera in an overlay, blur and infinite animations are heavy on a phone that is also encoding, the canvas was stretched by CSS, the poll bars added to 90 percent, color alone carried meaning, and an emoji was lost in the header.
Second mockup (the user's cleaner CAMP-US page): lighter and better, with a mobile breakpoint, tabular timer digits and safe text insertion. Problems: the API pointed at catinlens.com but the user's domain spelling varied, background still opaque so it cannot be the Larix overlay, 13px text too small for stream video, no devicePixelRatio handling, 100vh cut off on iPhone Safari, SessionStorage restore, hardcoded poll values and no connection code.
[Design proposal; unspecified choices are defaults.]
Signature Design Concept: the room is a machined readout and the campus map is a lit dial. It should feel like a precise instrument waking up, not a decorated game surface.
The public entry is the accepted room-code and name flow: one centred 400px column on #0A0A0A containing the CAMP-US wordmark, recessed room-code and name fields, a filled blue “Join room” action, an outlined “Watch” action, and the line “Crew complete tasks. Impostors sabotage. Nobody knows who.” It introduces no marketing hero, card grid, character art, or oversized headline.
Once entered, the same object becomes the game shell: a compact header with wordmark, room code, and timer; a radial campus canvas; and functional sidebar panels for crew tasks, feed, roster, audience poll, and sabotage. The room code is the stream-facing readout. The timer is the persistent danger signal. Sabotage is an explicit alert with a label, fix point, and countdown. Task progress is a restrained blue bar; feed, roster, and audience-prediction rows remain information-dense and readable.
Implementable public-entry composition: place the wordmark at the top of the centred column, stack the recessed room-code and name wells, then place “Join room” and “Watch” in one row. Keep the explanatory sentence beneath them. On successful entry, replace this composition in place with the existing lobby or game shell; do not add a new destination or behaviour. Preserve readable text and controls inside their containers at 375px, 768px, and 1280px, with safe-area handling and readable overlay contrast. The visual language covers entry, lobby, mission, meeting, results, and watching states without adding functionality.
identity-driven experience, we need to embed that psychology straight into the game loop so it shapes how players think, lie, and form rivalries.
Combining the raw freedom of choice (Option A) with the persistent identity rewards (Option B) creates the perfect balance. The goal is to make the game mechanics force social tension while rewarding players for developing a unique gaming persona.
[Design proposal; unspecified choices are defaults.]
Interaction Model: Animated Motion Tempo: restrained Hero Dimensionality: flat
Landing Hero Motion Brief Focal subject: the CAMP-US room-entry instrument—13px wordmark, recessed room-code and name fields, and the single blue Join room action on a quiet #0A0A0A field.
Input → transformation → outcome: an accepted room code and name submitted through Join room or Watch → the entry instrument crossfades in place → the lobby or game shell appears as though the display has woken rather than navigated to a new destination. Use a 200ms crossfade with no movement. The first frame is still: wordmark, two fields, two actions, and the muted explanatory sentence.
Use the direction’s mechanical, sparse vocabulary. Poll every 1000ms. Animate only timer digits with a 120ms opacity tick when seconds change and crew-task progress with a 240ms cubic-bezier(0.22,0.61,0.36,1) width transition. Panel state changes are instant colour or border swaps with a 120ms ease; sabotage does not slide in. Its ring pulses once every 2s at 0.6 opacity. Map nodes remain still. Button hover changes only the border to #444 over 120ms. Countdown display uses server-time skew.
Reduced-motion state: stop timer ticks and sabotage-ring pulses, set every transition to 0ms, and make the task bar jump to its final width while preserving the same usable entry, lobby, meeting, watching, and game arrangements.
if (window.ResizeObserver) new ResizeObserver(drawMap).observe(wrap);
else window.addEventListener('resize', drawMap);
@media (max-aspect-ratio: 1/1), (max-width: 700px) {
.game { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; }
.sidebar { max-height: 38dvh; }
}
@media (orientation: landscape) and (max-height: 520px) {
.game { grid-template-columns: 1fr 250px; grid-template-rows: minmax(0, 1fr); gap: 10px; }
.sidebar { max-height: none; }
.panel { padding: 8px 12px; }
}
</style>if (window.ResizeObserver) new ResizeObserver(drawMap).observe(wrap);
else window.addEventListener('resize', drawMap);
• My first answer said Larix could not load interactive pages. That was wrong. Larix has HTML layers and web widgets, so it can load a web page as an overlay. Some of this may need Larix Premium, and some user reviews mention broken web widgets in recent versions, so test before going live.
• The overlay is display only. It is drawn into the video, nobody can tap it, and sounds playing inside it are not mixed into the stream.
• A secret token in the URL leaks when the link is shown on stream. Use short lived single use tokens exchanged for a session.
• SessionStorage only survives a refresh, not a closed tab. Use localStorage plus a server side player record, and keep real positions on the server.
• The spec says server side randomness while the matrix says client side drops. Keep it server side. A 5 percent radar drop can break balance.
• Critical: every player's secret session token was sent to everyone, because the public id and the token were the same value. Anyone could impersonate the impostor.
• Lookups like SABOTAGE_TYPES[payload.type] and CAMPUSES[campus] accepted names such as constructor, giving a never ending sabotage or a server crash.
• Math.random for tokens and codes (guessable), room codes could be short or duplicated.
Testing done: syntax check, and a smoke test showing no token leaks between players and createRoom with campus constructor falling back to UNILAG. The sabotage lookup guard was not actually exercised by that test.
Rate limits per IP: 300 requests per 10 seconds in general, 10 room creations per minute, 60 joins per minute (join and audience). These are generous because a campus can share one IP.
• Known issue: names go into innerHTML in the chat and candidate list. Safe with the fixed roster, but an injection risk once players type their own names.
• Screenshot of the host folder: game.js, index.js, package.json under unilag-api.
• On the user's host at last report: the old game.js and old index.js, which have the security bugs and the broken import.
[hidden] { display: none !important; }
canvas { width: 100%; height: 100%; display: block; touch-action: none; }
@media (max-aspect-ratio: 1/1), (max-width: 700px) {
.game { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; }
.sidebar { max-height: 38dvh; }
}
@media (orientation: landscape) and (max-height: 520px) {
.game { grid-template-columns: 1fr 250px; grid-template-rows: minmax(0, 1fr); gap: 10px; }
.sidebar { max-height: none; }
.panel { padding: 8px 12px; }
}
</style>• My first answer said Larix could not load interactive pages. That was wrong. Larix has HTML layers and web widgets, so it can load a web page as an overlay. Some of this may need Larix Premium, and some user reviews mention broken web widgets in recent versions, so test before going live.
• Audio: Larix uses one audio input at a time. A Bluetooth mic replaces the phone mic instead of adding to it, and Bluetooth mics drop to call quality. To get mic plus game sound, mix before the phone (small mixer, USB audio interface, or a wireless mic receiver) or stream through OBS.
• A secret token in the URL leaks when the link is shown on stream. Use short lived single use tokens exchanged for a session.
• SessionStorage only survives a refresh, not a closed tab. Use localStorage plus a server side player record, and keep real positions on the server.
• Critical: every player's secret session token was sent to everyone, because the public id and the token were the same value. Anyone could impersonate the impostor.
• Hidden information leaked: everyone saw all locations and alive status, all bodies with names, and the feed announced kills.
• Lookups like SABOTAGE_TYPES[payload.type] and CAMPUSES[campus] accepted names such as constructor, giving a never ending sabotage or a server crash.
• Math.random for tokens and codes (guessable), room codes could be short or duplicated.
• Rooms live in memory, so a restart wipes them and serverless hosting cannot work.
Testing done: syntax check, and a smoke test showing no token leaks between players and createRoom with campus constructor falling back to UNILAG. The sabotage lookup guard was not actually exercised by that test.
Rate limits per IP: 300 requests per 10 seconds in general, 10 room creations per minute, 60 joins per minute (join and audience). These are generous because a campus can share one IP.
• Known issue: names go into innerHTML in the chat and candidate list. Safe with the fixed roster, but an injection risk once players type their own names.
Index · HTML
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <title>CAMP-US</title> <style> <script> (() => { const API = 'https://api.catinlens.com'; const COL = { accent: '#0071e3', danger: '#ff3b30', line: '#2a2a2a', muted: '#8a8a8a', text: '#f2f2f2' }; const $ = id => document.getElementById(id); async function api(path, body) { try { const r = await fetch(API + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const j = await r.json().catch(() => ({ error: 'Bad response from server' })); return { status: r.status, ...j };
} catch (e) { return { status: 0, error: 'Cannot reach the server' }; }
}
const wrap = $('canvas-wrap'), canvas = $('gameCanvas'), ctx = canvas.getContext('2d');
function drawMap() {
const dpr = window.devicePixelRatio || 1, w = wrap.clientWidth, ht = wrap.clientHeight;
if (!w || !ht) return;
canvas.width = Math.round(w * dpr); canvas.height = Math.round(ht * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = '#0d0d0d'; ctx.fillRect(0, 0, w, ht);
nodes = [];
public_html/game//home/catinlen/unilag-api/• Spellings used in the conversation: catinles.com, catinlens.com and catinlens.cm. A screenshot of the host file manager shows the path domains > catinlens.com > unilag-api, so catinlens.com is the one owned. The .cm ending (Cameroon) was a typo.
• The API runs as a Node.js app at api.catinlens.com. The unilag-api folder contained game.js (28.35 KB), index.js (5.07 KB) and package.json (205 bytes).
• Hosting a page needs no API subdomain. Netlify or the main site can serve static files over HTTPS. A server is only needed for live multiplayer sync.
• Netlify cannot hold live connections. Options for sync: Firebase, Supabase Realtime, Ably, PartyKit, Colyseus, or a small Node server. In-memory room storage cannot run on serverless functions.
• Same host or separate: a path such as catinlens.com/api works if the page and server share a host. A separate server uses a subdomain with a DNS record. Use wss:// when the page is on HTTPS. On shared Node hosting WebSockets may not work, and polling once a second is fine for this game.
ESM module for Node 18 or newer (uses node:crypto). To be saved on the host as game.js. Exports: createRoom, actInRoom, joinAudience, getRoomState, getAudiencePredictions, cleanupRooms, roomCount.
Express server, imports ./game.js. Port from PORT or 5000. Allowed origins default to https://catinlens.com and https://www.catinlens.com (override with ALLOWED_ORIGINS). Behind a proxy, trust proxy is set to 1.
Changes from the user's version: CORS restricted (was open to every site), the unauthenticated admin cleanup route removed (cleanup runs every 60 seconds), type and length checks on all input, 10 KB body limit, no-store cache headers, JSON error handling, and the plural route alias now shares one handler.
Routes: GET /health; POST /api/rooms/create, /join, /state, /action (also /actions) and /audience; GET /api/rooms/:code/predictions. State is polled once per second. A wrong session token returns 404. The server is plain HTTP polling, not WebSockets. Syntax checked but not run, because Express was not installed in my environment.
Deployment checklist: upload room-engine.js as game.js, upload index.js, package.json needs type module, express, cors and Node 18 or newer, check https://api.catinlens.com/health, restarts wipe all rooms.
• Multiplayer hooks the author left: each actor has ctrl local or ai (remote planned) and game changing moves go through an Actions object. But all state is inside one closure and the browser holds who the Fake is, so real multiplayer needs a server in charge.
• HIDE_AND_SEEK.html (uploaded by the user): the finished single player game.
• Three uploaded map images: UNILAG, UI, Babcock.
• index.html (built by me, v3): the multiplayer page. Do not upload it over the user's game.
• room-engine.js (built by me, v2 with server time): save as game.js on the host.
• index.js (built by me, v2): the API server.
Index · HTML
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <title>CAMP-US</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; } <script> (() => { const API = 'https://api.catinlens.com'; const COL = { accent: '#0071e3', danger: '#ff3b30', line: '#2a2a2a', muted: '#8a8a8a', text: '#f2f2f2' }; const $ = id => document.getElementById(id); }, 300);• The API runs as a Node.js app at api.catinlens.com. The unilag-api folder contained game.js (28.35 KB), index.js (5.07 KB) and package.json (205 bytes).
• Hosting a page needs no API subdomain. Netlify or the main site can serve static files over HTTPS. A server is only needed for live multiplayer sync.
• Netlify cannot hold live connections. Options for sync: Firebase, Supabase Realtime, Ably, PartyKit, Colyseus, or a small Node server. In-memory room storage cannot run on serverless functions.
• Same host or separate: a path such as catinlens.com/api works if the page and server share a host. A separate server uses a subdomain with a DNS record. Use wss:// when the page is on HTTPS. On shared Node hosting WebSockets may not work, and polling once a second is fine for this game.
• The user's original index.js imported ./campus-engine.js, a file that was not in the folder, so it would have crashed.
• Peer to peer WebRTC for 20 players needs 19 streams each. Use an SFU such as LiveKit with server side gating of who hears whom. Hardware accelerated WebRTC is not something you configure. Voice with strangers needs mute, report and moderation.
ESM module for Node 18 or newer (uses node:crypto). To be saved on the host as game.js. Exports: createRoom, actInRoom, joinAudience, getRoomState, getAudiencePredictions, cleanupRooms, roomCount.
Express server, imports ./game.js. Port from PORT or 5000. Allowed origins default to https://catinlens.com and https://www.catinlens.com (override with ALLOWED_ORIGINS). Behind a proxy, trust proxy is set to 1.
Changes from the user's version: CORS restricted (was open to every site), the unauthenticated admin cleanup route removed (cleanup runs every 60 seconds), type and length checks on all input, 10 KB body limit, no-store cache headers, JSON error handling, and the plural route alias now shares one handler.
Routes: GET /health; POST /api/rooms/create, /join, /state, /action (also /actions) and /audience; GET /api/rooms/:code/predictions. State is polled once per second. A wrong session token returns 404. The server is plain HTTP polling, not WebSockets. Syntax checked but not run, because Express was not installed in my environment.
Deployment checklist: upload room-engine.js as game.js, upload index.js, package.json needs type module, express, cors and Node 18 or newer, check https://api.catinlens.com/health, restarts wipe all rooms.
• room-engine.js (built by me, v2 with server time): save as game.js on the host.
• index.js (built by me, v2): the API server.
Claude finished the response
• Setup: iPhone 12 running Larix Broadcaster, the game played on a second device, audience on Twitch.
• Domain: catinlens.com (see section 3).
• Audio: Larix uses one audio input at a time. A Bluetooth mic replaces the phone mic instead of adding to it, and Bluetooth mics drop to call quality. To get mic plus game sound, mix before the phone (small mixer, USB audio interface, or a wireless mic receiver) or stream through OBS.
• Charging: the iPhone 12 uses Lightning. Use Apple's Lightning to USB 3 Camera Adapter, with the charger (20W or more) in the Lightning port and USB audio gear in the USB port. Use a powered interface or hub so the phone battery is not drained. MagSafe wireless charging frees the port but charges slower and adds heat. A USB-C phone would use a hub with power pass-through instead.
• The user's original index.js imported ./campus-engine.js, a file that was not in the folder, so it would have crashed.
My criticisms:
• Scope is a small studio's project. Build a playable core with 6 to 8 players first.
• Wagers are fine with free points. Anything bought with real money runs into Twitch gambling policy and the law.
• Twitch Extensions need a backend service and review, cannot post a payload straight into chat, and chat cannot show panels. Subscriber checks need OAuth and linked accounts, which conflicts with zero account entry. Join tokens posted in chat are public.
• The matrix is a strawman (see section 14).
A separate transparent overlay page for Larix (large text, no background) was proposed but not built.
• Rooms live in memory, so a restart wipes them and serverless hosting cannot work.
Campuses: unilag (8 locations), oau (8), ui (6), babcock (6). The location graph is a ring plus shortcuts (placeholder, to be replaced by real layouts).
Not built: meeting chat, Twitch voting, the transparent Larix overlay, real campus layouts.
• Rule based hints from game events are risky because they can leak information if players watch the stream. Never give the bot the roles. The audience-sus action was proposed but not written.
• To add UI or Babcock to the user's game, define about 8 rooms, corridors, obstacles, stations and a pathfinding graph for each, and confirm the layout with the user first.
• I first said Larix could not load interactive pages. It can load HTML layers.
• I treated index.html as the user's game and built a new page. The real game is HIDE_AND_SEEK.html, which is far more complete.
• The smoke test did not exercise the sabotage lookup guard, as noted in section 7.
• Choose option A, B or C.
• Confirm: which files are currently deployed, what package.json contains (not yet seen), whether the host supports WebSockets, and whether the Larix web overlay works on the user's version.
CAMP-US
• Setup: iPhone 12 running Larix Broadcaster, the game played on a second device, audience on Twitch.
• Domain: catinlens.com (see section 3).
• Charging: the iPhone 12 uses Lightning. Use Apple's Lightning to USB 3 Camera Adapter, with the charger (20W or more) in the Lightning port and USB audio gear in the USB port. Use a powered interface or hub so the phone battery is not drained. MagSafe wireless charging frees the port but charges slower and adds heat. A USB-C phone would use a hub with power pass-through instead.
• Spellings used in the conversation: catinles.com, catinlens.com and catinlens.cm. A screenshot of the host file manager shows the path domains > catinlens.com > unilag-api, so catinlens.com is the one owned. The .cm ending (Cameroon) was a typo.
My criticisms:
• Scope is a small studio's project. Build a playable core with 6 to 8 players first.
• Wagers are fine with free points. Anything bought with real money runs into Twitch gambling policy and the law.
• Twitch Extensions need a backend service and review, cannot post a payload straight into chat, and chat cannot show panels. Subscriber checks need OAuth and linked accounts, which conflicts with zero account entry. Join tokens posted in chat are public.
• The matrix is a strawman (see section 14).
A separate transparent overlay page for Larix (large text, no background) was proposed but not built.
Campuses: unilag (8 locations), oau (8), ui (6), babcock (6). The location graph is a ring plus shortcuts (placeholder, to be replaced by real layouts).
Not built: meeting chat, Twitch voting, the transparent Larix overlay, real campus layouts.
• Rule based hints from game events are risky because they can leak information if players watch the stream. Never give the bot the roles. The audience-sus action was proposed but not written.
Title in the code: WHO BE THE FAKE? UNILAG Edition. One file of 148,374 bytes and about 2,120 lines, no network calls (only a link to share on Twitter), so it runs as a static page.
• To add UI or Babcock to the user's game, define about 8 rooms, corridors, obstacles, stations and a pathfinding graph for each, and confirm the layout with the user first.
Three options were offered: (A) keep the single player game and stream it, adding a chat bridge so Twitch chat influences meetings or sabotage, plus a Larix overlay tally; (B) real multiplayer of the user's game with a server simulating movement; (C) reskin the location based engine with the game's rooms and quick chat. Recommendation: A first, since the game is finished and the audience hook is where the stream gets its fun. No choice has been made yet.
• HIDE_AND_SEEK.html (uploaded by the user): the finished single player game.
• Three uploaded map images: UNILAG, UI, Babcock.
• index.html (built by me, v3): the multiplayer page. Do not upload it over the user's game.
• Screenshot of the host folder: game.js, index.js, package.json under unilag-api.
• On the user's host at last report: the old game.js and old index.js, which have the security bugs and the broken import.
• I first said Larix could not load interactive pages. It can load HTML layers.
• I treated index.html as the user's game and built a new page. The real game is HIDE_AND_SEEK.html, which is far more complete.
• The smoke test did not exercise the sabotage lookup guard, as noted in section 7.
• Choose option A, B or C.
• Confirm: which files are currently deployed, what package.json contains (not yet seen), whether the host supports WebSockets, and whether the Larix web overlay works on the user's version.
CAMP-US — The campus-based game specified in this SRD.
Campus — A distinct university setting with its own map and locations. The supported campuses are UNILAG, OAU (Great Ife), University of Ibadan, and Babcock.
Location — A named place on a campus map represented by a discrete identifier and display name rather than 2D coordinates.
UNILAG — A campus map including Lagoon Front, Love Garden, Moremi Hall Garden, UBA Park, 2001 Cafeteria, Main Library, Sports Centre, and the Jaja, Moremi, Mariere, Amina, Tinubu, and Fagunwa halls.
OAU (Great Ife) — A campus map including the Amphitheatre, Oduduwa Hall, Hezekiah Oluwasanmi Library, Banking Area, Student Union Building, Awolowo Hall, Fajuyi Hall, Mozambique Hall, Angola Hall, Health Centre, Sports Complex, Motion Ground, and New Market.
University of Ibadan (UI) — A campus map including Awba Dam, SUB, S. Bello Hall, Inde Hall, UI Zoo, and the Agric area.
Babcock — A campus map including Amphitheatre/Church, New Horizon, Campus Bakery, Stadium, BUTH Gate, The Walk, and premium and regular halls.
No completed page designs yet.
Completed design pages will appear here when they are ready to preview.
No completed page designs yet.
Completed design pages will appear here when they are ready to preview.
No comments yet. Be the first!