2026-10-11T09:46:58.212057 USER: https://8080.ai/shared/artifacts/ec2a253c-14b9-4ec9-8014-1ad4126963c1 https://8080.ai/shared/artifacts/2d0f2085-732a-4580-af4b-e6ad6823a1da 2026-10-11T09:59:16.626361 USER: Description Objective Generate the system requirements document in replit-com.
Project context and inputs User request: just make it and with nice design, and apple looking uiux Work to perform Translate the recorded product brief into explicit capabilities, actors, scope and constraints. Describe each declared page and its purpose; retain source-backed technical and visual requirements. Separate stated requirements from assumptions and surface unresolved details for review. Expected output A reviewable system requirements document for this project. Completion checks The document covers the recorded product intent and declared pages/personas.
Constraints and exclusions remain intact; unsupported requirements are not introduced. 2026-10-11T10:00:04.034685 USER: html and for catainlens.com 2026-10-11T10:00:20.374514 USER: catinlens.com 2026-10-11T10:00:33.727704 USER: User chose project name pearl-8080 2026-10-11T10:04:17.100469 USER: just build the html files already 2026-10-11T10:06:20.406706 USER: Server sync between devices is the one piece a static HTML file can't do on its own; is this about engine and others? 2026-10-11T10:07:11.316237 USER: make everything now 2026-10-11T10:14:00.154939 USER: have you written any code? 2026-10-11T10:14:34.803063 USER: why then does your planning keep failing wasting my credits intentionally 2026-10-11T10:15:46.584853 USER: finish the task and give me the files i need 2026-10-11T10:19:31.046922 USER: youve just been reading back to me details i gave you with whole 5 credits 2026-10-11T10:20:25.905969 USER: you guys should pack upfrfr
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
CAMP-US — Full Game Specification
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.
• 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.
flowchart TD
flowchart TD
flowchart TD
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.
<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;
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.
.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.
N10["Game: 8. Select kill target at current location Impostor"]
N11["Game: 9. Confirm kill action with cooldown check Impostor"]
N12["Game: Trigger sabotage type selection Impostor"]
N13["Game: View active sabotage alert with fix point and countdown"]
N14["Game: Check if at sabotage fix location Crew"]
N15["Game: Fix sabotage requires 2 crew present, or 1 if last"]
N16["Game: 10. Report discovered body"]
N17["Game: 11. Call emergency meeting limited uses remaining"]
N18["Meeting: 12. View meeting call and living roster"]
N19["Meeting: 13. Cast vote against suspected impostor or skip"]
N20["Meeting: 14. View vote tally result and ejection outcome"]
N7 -->|"Task started, minimum 4s elapsed"| N8
N8 -->|"Task completed, progress updates"| N9
N5 -->|"Role is Impostor, sabotage cooldown ready, no active sabotage"| N12
N12 -->|"Sabotage triggered"| N13
N13 -->|"Crew member checks location against fix point"| N14
N14 -->|"Crew member is at fix location with required headcount"| N15
N6 -->|"Body present at current location"| N16
N4["Lobby: 2. Check prerequisite: at least 5 players joined"]
N6["Game: 4. View assigned role Crew or Impostor"]
N7["Game: 5. Move between campus locations"]
N8["Game: 6. Start a task"]
N9["Game: 7. Complete a task finish after wait"]
N10["Game: 8. View remaining/completed crew task progress"]
N11["Game: Trigger sabotage impostor"]
N12["Game: View sabotage alert and fix-point countdown"]
N13["Game: Fix sabotage crew, at fix point"]
N14["Game: 9. Kill target impostor"]
N15["Game: 10. Report a body"]
N16["Game: Call emergency meeting host as player"]
N17["Meeting: 11. View meeting call and accused context"]
N18["Meeting: 12. Vote on suspect or skip"]
N19["Meeting: 13. View vote tally and ejection result"]
N20["Results: 14. View round winner and roster roles"]
N21["Results: 15. Restart round / Play again host"]
N16 -->|"Emergency meeting called host has emergencies left"| N17
N17 -->|"Player alive and has not voted"| N18
N18 -->|"All votes cast or timer ends"| N19
N19 -->|"Round continues no winner yet"| N7
N10 -->|"All crew tasks done"| N20
N19 -->|"Win condition met"| N20
N14 -->|"Impostors equal/outnumber crew"| N20
N20 -->|"Host views results"| N21
N21 -->|"Restart returns all to lobby"| N3
<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.
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. |
H('div', { class: 'list' }, S.roster.map(p => H('div', {}, p.name + (p.isHost ? ' (host)' : '') + (me && p.id === me.id ? ' (you)' : '')))),
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>N3 -->|"Player is host"| N4
N5 -->|"Role is Crew and at task location"| N7
N5 -->|"Role is Impostor and alive"| N10
N10 -->|"Target selected and kill cooldown expired"| N11
N1["Landing: Enter name and room code"]
N2["Landing: Submit Join room"]
N3["Lobby: 1. View roster and wait for host to start"]
N4["Lobby: 2. Check player count = 5 host start gating"]
N5["Game: 3. View assigned role Crew or Impostor and current location"]
N6["Game: 4. Move to adjacent campus location"]
N7["Game: 5. Start a task Crew"]
N8["Game: 6. Wait minimum task time then Finish task Crew"]
N9["Game: 7. View remaining/completed task progress bar"]
N21["Game: Leave game exit session"]
N22["Results: 15. View round winner and full role reveal"]
N23["Results: 16. Wait for host to restart non-host or return to Lobby"]
N1 -->|"Name and room code entered"| N2
N2 -->|"Join succeeds, room in lobby phase"| N3
N4 -->|"Host starts game with =5 players; roles assigned"| N5
N3 -->|"Host starts round non-host players transition automatically"| N5
N5 -->|"Player alive during mission phase"| N6
N5 -->|"Player alive and has emergency meetings remaining"| N17
N16 -->|"Body reported, meeting begins"| N18
N17 -->|"Emergency meeting called"| N18
N18 -->|"Player is alive and has not voted"| N19
N19 -->|"All votes cast or timer expires"| N20
N20 -->|"Round continues no win condition met"| N5
N20 -->|"Win condition met after ejection"| N22
N11 -->|"Win condition met impostors equal/outnumber crew"| N22
N9 -->|"All crew tasks completed Crew win"| N22
N5 -->|"Timer runs out Impostor win"| N22
N22 -->|"Results displayed"| N23
N23 -->|"Host restarts game, returns to Lobby"| N3
N5 -->|"Player chooses to leave mid-game"| N21
N3 -->|"Player chooses to leave lobby"| N21
N1["Landing: Enter name and select campus"]
N2["Landing: Create room host"]
N3["Lobby: 1. View roster and invite link"]
N5["Lobby: 3. Start game host"]
N22["Landing: Join room as player"]
N23["Landing: Watch as spectator"]
N24["Game: View audience prediction poll spectator"]
N2 -->|"Room created"| N3
N3 --> N4
N4 -->|"5 or more players in lobby"| N5
N5 -->|"Host starts round; roles assigned"| N6
N6 --> N7
N7 -->|"Crew role, at task location"| N8
N8 -->|"After minimum task wait time"| N9
N9 --> N10
N6 -->|"Impostor role, cooldown ready"| N11
N11 -->|"Sabotage active"| N12
N12 -->|"Crew present at fix point"| N13
N6 -->|"Impostor role, target co-located, kill cooldown ready"| N14
N14 -->|"Body present at location"| N15
N15 -->|"Body reported"| N17
N1 -->|"Name and campus entered"| N2
N22 -->|"Name and room code entered"| N3
N23 -->|"Joined as spectator"| N24
N24 -->|"Round ends"| N20
N1["Landing: Enter name and room code"]
N2["Landing: Choose Watch to join as spectator"]
N3["Landing: Submit watch request to join room as audience"]
N4["Lobby: View lobby roster and room code while waiting as spectator"]
N5["Game: View full campus map with all players, locations, and bodies spectator view"]
N6["Game: Check if prediction window is open 90 seconds into round"]
N7["Game: Submit prediction: Crew wins"]
N8["Game: Submit prediction: Impostors win"]
N9["Game: View sabotage alert panel with fix point and countdown"]
N10["Game: View feed of round events"]
N11["Game: Leave the game/room"]
N12["Results: View round results and roster with roles revealed"]
N13["Results: Check whether prediction was correct and Impostor survived reward condition"]
N14["Results: Wait for host to restart no restart control for spectator"]
N1 -->|"Spectator chooses to watch rather than play"| N2
N2 --> N3
N3 -->|"Room is in lobby phase"| N4
N3 -->|"Room is already in mission/meeting phase"| N5
N4 -->|"Host starts the game"| N5
N5 --> N6
N6 -->|"Prediction window open and audienceId present"| N7
N6 -->|"Prediction window open and audienceId present"| N8
N5 -->|"Sabotage is active"| N9
N5 --> N10
N5 -->|"Spectator chooses to leave"| N11
N5 -->|"Round ends phase becomes results"| N12
N12 -->|"Spectator made a prediction earlier"| N13
N12 --> N14
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.)[Design proposal; unspecified choices are defaults.]
Muse: Jony Ive. Headline: “Quiet precision after Jony Ive — a black instrument panel, not a game skin.” CAMP-US uses a clean, serious, Apple-like dark interface for Nigerian university campus rooms and Larix/Twitch audiences. Use #0A0A0A page ground, #111111 panels, #0D0D0D recessed canvas and form wells, #1C1C1C instrument surfaces, and #222222 1px hairlines. Primary text is #F2F2F2; labels and metadata are #8A8A8A; #0071E3 is the sole blue status colour; #FF3B30 is reserved for danger, including timer digits, sabotage borders, body markers and kill actions. Alert surfaces use #1A0D0C or #2A1210 with red borders. Colour never carries meaning alone: red states retain labels such as “Sabotage,” “Fix point,” or “x”; blue states retain labels such as “you,” “here,” or “Join room.”
Use Inter Tight for headings, the CAMP-US wordmark and room code, and Inter for body copy; the -apple-system stack is fallback only. Use the 12/13/15/19/24/30/38px scale. Body text is 15px/1.45; micro-labels are 12px uppercase with 0.08em tracking; panel headings are 14px; the wordmark is Inter Tight 700, 13px, all caps, with 0.08em tracking; room codes use Inter Tight 700, tabular numerals, 24px mobile to 38px desktop; timers use Inter 600, 15px, tabular numerals, with 0.04em tracking; results verdicts are 30–38px. No display-size marketing headline appears in the game shell.
Use continuous-curve radii: 10px panels, 8px inputs and buttons, 3px progress caps, 20px map nodes and a 27px sabotage ring. Do not use shadows, gradients, glows, blur, glassmorphism, neon, decorative icons, characters, stock imagery or AI imagery. The full-height 100dvh shell uses safe-area padding and 16px gutters. Desktop uses a flexible main column and 320px scrollable sidebar with 12px gaps. Portrait and narrow layouts place the map above a sidebar capped at 38dvh; landscape phones below 520px high use a 1fr/250px layout with 8px 12px panel padding. Entry and lobby share a centred 400px column with an 8vh top margin. Primary controls use #0071E3, secondary controls #1A1A1A, disabled controls 0.4 opacity, and focus-visible a 2px #0071E3 outline with 2px offset. Content and controls remain fully readable inside 375px, 768px and 1280px viewports.
The sole crafted visual is a DPR-aware radial campus dial on #0D0D0D: 1.5px #222222 link paths; 20px nodes; the current node filled #0071E3; reachable nodes ringed #0071E3; other nodes #161616 with #333333 rings; 600-weight 13px player counts; red “x” body markers offset +23,-22; #8A8A8A location labels truncated at 16 characters; and a #FF3B30 27px fix-point ring. Progress bars are 6px with #1C1C1C tracks and blue fills. Toasts are fixed 12px from the top, max-width 90vw, with a red border and near-black red surface.
• 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);
}
}
: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: CAMP-US is a machined social-deduction instrument for campus rooms and their streaming audience. The public entry is the product instrument rather than a marketing hero: a full-viewport #0A0A0A field contains one centred 400px column with the tracked CAMP-US wordmark, recessed room-code and name wells, filled “Join room” and outlined “Watch” controls, and the line “Crew complete tasks. Impostors sabotage. Nobody knows who.” It uses no oversized headline, card grid, character art or decorative imagery.
After entry, the same object becomes the game shell: CAMP-US, room code and timer in the header; a recessed radial campus canvas; and functional sidebar panels for tasks, feed, roster, audience poll, sabotage, role, actions, meetings and results. The room code is the machined readout centred between wordmark and timer. The timer is the persistent danger signal. The map communicates location, reachability, player counts, bodies and sabotage. Hairline panels expose crew progress, feed lines, roster suffixes such as “(host)” and “(you),” and numbered audience rows supporting !1, !2 and !3. Alerts retain the #111111 surface and switch only the border to #FF3B30, with explicit “Sabotage” and “Fix point” text. Depth comes from luminance steps between ground, panels and recessed wells; every visible element supports playing, watching or streaming.
Necessary default: where the source does not define an additional visual treatment, retain this flat, functional instrument language rather than adding decorative imagery or controls.
Instead of cosmetic items that just look cool, identity should be an active tool in the psychological warfare.
[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 centred CAMP-US room-entry instrument—13px wordmark, recessed room-code and name wells, “Join room” and “Watch” controls, and the one-line premise—on the quiet #0A0A0A field. Input→transformation→outcome: an accepted room code and name submitted through Join room or Watch replaces the entry instrument in place with the accepted lobby, game shell or audience view through a 200ms crossfade with no movement; the display appears to wake rather than navigate. The composed first frame is still, centred and sparse, with one saturated blue primary control and no competing imagery.
Motion is restrained, mechanical and physical. Poll for updates every 1000ms. Server-time-skewed timer digits receive a 120ms opacity tick when seconds change. Crew-task progress uses a 240ms cubic-bezier(0.22,0.61,0.36,1) width transition. Panel changes are instant colour or border swaps over 120ms ease; sabotage does not slide in. The sabotage ring pulses once every 2s at 0.6 opacity; map nodes and the fix-point ring otherwise remain still. Button hover changes only the border to #444 over 120ms. Do not use scroll animation, parallax, particles, bounce or background motion.
With prefers-reduced-motion, stop timer ticks and ring pulses, set transitions to 0ms, make the task bar jump to its final width, and preserve the usable arrangement. Necessary default: without a source-defined transition, use the same restrained, non-decorative motion ceiling.
[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.
Project name: replit-com.
Replit project/join URL: https://replit.com/join#t=ykbwmstneuzwcyn-otolorincyber2
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.
Claude finished the response
• Setup: iPhone 12 running Larix Broadcaster, the game played on a second device, audience on Twitch.
• 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.
• Domain: catinlens.com (see section 3).
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.
reative Director
Direction set
The muse
Jony Ive
Apple's industrial and interface design; restraint, precision, material honesty
Quiet precision after Jony Ive — a black instrument panel, not a game skin CAMP-US is a real-time multiplayer social-deduction game played on Nigerian university campuses (UNILAG, OAU, UI, Babcock) with up to 20 players, unlimited Twitch spectators and a streamer who may be the Impostor. The audience is students and stream viewers, often on a phone, often watching a stream on the same screen. The emotional register is tension held in stillness: suspicion, bluffing, the courtroom-theatre meeting, the countdown to a sabotage. The brief explicitly asks for a clean, serious, Apple-like interface — system type, dark ground, subtle borders, no neon, no AI visuals, no gacha — matching catinlens.com.
The user's own words are literally Ive's language: dark, quiet, precise, material-honest, nothing decorative that is not functional. This is a long-session, high-tension instrument that must be readable on a phone while encoding a stream — so restraint is not a style choice, it is the product. Ive is the muse whose palette, type and motion logic already match the requested Apple look, so the design work goes into craft (the map as a lit dial, the timer as a machined readout) rather than into decoration. The requested blue #0071e3 and red #ff3b30 are kept exactly as the accent and danger signal, used sparingly as instrument lights.
Confidence
86%
Palette
Dark mode
Background
#1C1C1C
Surface
#111111
Text
#F2F2F2
Primary
#0071E3
Accent
#0071E3
Muted
#8A8A8A
Get started
Exactly the user's tokens. #0a0a0a page ground; #111111 panels floating on it with 1px #222222 hairlines; #0d0d0d canvas well (recessed, one shade darker than the panel so the map reads as a lit instrument inside a housing). Text #f2f2f2 at 15px body / #8a8a8a for labels, feed lines and secondary metadata. #0071e3 is the ONLY primary: it marks 'you' (your node, your row), the crew-task progress fill, and the single primary button. #ff3b30 is the danger signal, reserved for the timer digits, sabotage alert borders, body markers and the kill action — never used for decoration or for more than one thing on screen at a time. Alert surfaces use a near-black red-tinted panel (#1a0d0c) rather than a bright red fill. Contrast: #f2f2f2 on #111111 = 15.6:1; #8a8a8a on #111111 = 5.6:1; white on #0071e3 = 3.9:1 so primary-button labels use a 600 weight at 15px minimum.
Typography
Aa
Heading
Inter Tight
Body
Inter
Quiet precision after Jony Ive — a black instrument panel, not a game skin
Every page is set in this pairing, from the hero line to the smallest caption.
38
Display
30
Headline
24
Title
19
Lead
15
Body
14
Caption
Inter Tight 600 for panel headings and the wordmark, 700 for the room code, with -0.02em tracking and sentence case; the CAMP-US wordmark is set in Inter Tight 700 at 0.08em positive tracking, all caps, 13px, reading as an engraved housing label rather than a logo. Numbers (room code, timer, player counts, task counts) are always tabular via font-variant-numeric: tabular-nums, and the timer is set in Inter 600 at 15px with 0.04em tracking so the digits do not jitter as they count down. Body copy 15px/1.45. No display-size headline anywhere in the app shell — the largest type on the page is the room code and the results verdict, which is the point.
01
Shape language
Continuous-curve radii only: 10px panels, 8px inputs and buttons, 3px progress bar caps, 20px map nodes with a 27px sabotage ring. One hairline weight (1px, #222222) everywhere — never 2px except the sabotage ring and the focus outline. No shadows, no glows, no gradients on any surface: depth comes from a single step of luminance between #0a0a0a ground, #111111 panel and #0d0d0d recessed canvas. Nothing decorative that is not also functional — every border is a divider, every colour is a status.
10px
02
Layout
The user's grid, kept: 1fr / 320px at desktop, full-height 100dvh with safe-area padding on all four sides, 16px gutters. Main column: 13px header row (CAMP-US wordmark left, room code centre, timer right), then the canvas well filling all remaining height. Sidebar: stacked panels in a scrollable column, 12px gap. Portrait and narrow (max-width 700px or max-aspect-ratio 1/1): single column, map on top filling the viewport, sidebar below capped at 38dvh. Landscape phone (max-height 520px): 1fr / 250px, panels tighten to 8px 12px padding. Entry and lobby are the same 400px centred column, max-width 400px, top margin 8vh, so joining a room feels like the same object as playing it.
03
Motion
Restrained, mechanical, physical. Polling is 1000ms; the interface never animates on its own except the timer digits (a 120ms opacity tick when the second changes) and the crew-task bar fill (240ms cubic-bezier(0.22,0.61,0.36,1) width transition). Panel state changes are instant colour/border swaps with a 120ms ease — a sabotage alert does not slide in, it is simply lit. Map nodes have no idle animation; the sabotage ring pulses once every 2s at 0.6 opacity, and the fix-point node gets a static 27px #ff3b30 ring. Hover on buttons lifts only the border to #444 over 120ms; no transform, no bounce, no particles. prefers-reduced-motion: the timer tick and ring pulse stop entirely, the task bar jumps to its final width, and all transitions become 0ms.
04
Imagery
No illustration, no characters, no stock people, no AI imagery, no neon. The only image in the product is the canvas: a radial campus dial drawn with 20px filled nodes on a #0d0d0d ground, 1.5px #222222 link paths, your node filled #0071e3, adjacent nodes ringed #0071e3, everything else #161616 with a #333 ring, player counts as 600 13px #f2f2f2 numerals inside the node, bodies as a red 'x' offset at 23,-22, and location names as 12px #8a8a8a labels below each node, truncated at 16 characters. The dial is the product's one crafted object — it should look like a machined face, not a diagram.
Hero direction
The first screen. The first screen is not a marketing hero; it is the instrument itself, and it must be unmistakable. On load, a full-viewport #0a0a0a field holds a single 400px centred column: the CAMP-US wordmark in 13px caps with 0.08em tracking at the top, then a 24px room code field and a 15px name field as two recessed #0d0d0d wells with 1px #222222 borders and 8px radii, then a row of two buttons — 'Join room' filled #0071e3 with white 600 text, 'Watch' outlined #222222 on #1a1a1a. Beneath, one line of #8a8a8a 13px text: 'Crew complete tasks. Impostors sabotage. Nobody knows who.' No headline larger than 24px, no gradient, no blob, no card grid — the restraint IS the hero, and the only saturated pixel on the screen is the single blue button. When a room is entered, that centred column is replaced in place by the game shell: the same 13px header row, the canvas well filling the viewport height, and the sidebar. The transition between the two states is a 200ms crossfade with no movement, so joining a room feels like a display waking up rather than a page changing.
Motion tempo
Still
Restrained
Expressive
Cinematic
Dimensionality
Flat
Layered 2D
Dimensional CSS
WebGL
Drama
Quiet
Composed
Bold
Immersive
Signature moves
The room code is set as a machined readout: 24px Inter Tight 700, tabular numerals, 0.04em tracking, in a recessed #0d0d0d well with a 1px #222222 border, sitting dead centre of the header row between the CAMP-US wordmark and the timer — the one piece of type a viewer can read from across a stream.
The timer is the only red on the page: #ff3b30, tabular numerals, 15px Inter 600, right-aligned in the header, with a 120ms opacity tick each second and a static 'data-end' countdown driven by server-time skew so a phone clock can never disagree with the room.
The campus map is drawn as a radial instrument face: nodes on a 20px radius circle with the current location filled #0071e3 and reachable neighbours ringed #0071e3, location names as 12px #8a8a8a labels beneath each node, player counts as 600 13px numerals inside the node, bodies as a red 'x' at the node's upper right, and the sabotage fix point carrying a 27px #ff3b30 ring — one glance tells you where you are, what you can reach, and what is on fire.
The sidebar is a stack of hairline panels, each one a 10px-radius #111111 card with a 1px #222222 border and 14px 16px padding: Crew tasks with a 6px progress bar whose fill is #0071e3, the feed as 13px #8a8a8a lines, the roster as plain rows with '(host)' and '(you)' suffixes in #8a8a8a, and the audience poll as numbered rows so a Twitch viewer can type !1, !2, !3. Panels never lift on hover and never animate in — they are simply present or absent.
Alerts are border-only: a sabotage panel keeps the same #111111 surface and swaps its 1px border to #ff3b30, with the sabotage label as a 14px h3 and 'Fix point: <name>. Ends in <timer>' as 13px #8a8a8a with the countdown in red. The toast is the same treatment, fixed 12px from the top, max-width 90vw, so nothing ever covers the map or a control.
Avoid
Any blue or indigo other than the exact #0071e3 — no #2563EB, #6366F1, #7C3AED or gradient variants of the accent.
Gradients, glows, neon, glassmorphism, blur and infinite background animation; the earlier mockup's frosted panels and neon palette are explicitly rejected.
Inter, Roboto, Arial, Helvetica, Open Sans, Lato, Poppins or system-ui as a named heading or body family — Inter Tight and Inter are the only named families, and the user's -apple-system stack remains only as a fallback behind them.
Display-size marketing headlines, hero blobs, or a grid of identical hover-lift cards anywhere in the shell.
Colour as the only carrier of meaning: every red state also carries a word ('Sabotage', 'Fix point', 'x' for a body), and every blue state also carries a label ('you', 'here', 'Join room').
Emoji in the header or panels, decorative icon sets, or any illustration that is not the map dial.
Animation on scroll, parallax, particles, or anything that costs frame budget while the same phone is encoding a stream.
Raising the body size above 15px or the micro-label above 12px — the interface must stay legible as a Larix overlay and as a 375px phone screen without reflowing the grid.
The brief
3
people
·
5
pages
Build CAMP-US, a multiplayer social deduction game on real Nigerian university campuses, living on catinlens.com with a clean, serious, Apple-like design (system fonts, dark background, subtle borders, no neon, no AI-themed visuals, no gacha effects) matching the existing catinlens.com look. Players are Crew or Impostor; crew complete tasks around the campus, the Impostor sabotages and tries to survive, and everyone can call meetings and vote. Campuses each have their own map and locations with the same rules: UNILAG, OAU (Great Ife), University of Ibadan, and Babcock. Up to 20 players per room, unlimited audience, host can be the Impostor, roles are Crew or Impostor. Round flow: host creates a room and shares an invite link; players join with a name or via the link; host starts the round and roles are assigned; players move between campus locations and complete tasks; the Impostor can sabotage with temporary effects; anyone can call a meeting and players vote with majority ejecting someone. Round ends when all tasks are done (Crew win), the Impostor is voted out (Crew win), the Impostor equals or outnumbers remaining Crew (Impostor win), or the timer runs out (Impostor win). Audience can join a room as spectators, can predict who the Impostor is using numbers for easy Twitch chat input, and if the Impostor survives to the end correct predictors win something (channel points, shoutout, or vanity reward). Invite links https://catinlens.com/game?code=ABC123 and https://catinlens.com/game/room/ABC123 open the game with the room code already filled; players enter a name and join; no accounts required. Map shows the campus; sidebar shows players, tasks, feed, and audience poll. Technical core: static HTML/CSS/JS frontend in public_html/game/, Node.js/Express backend in /home/catinlen/unilag-api/, in-memory rooms (no database), polling for updates, short-lived invite tokens, server owns all game state, deployed on the same cPanel hosting as catinlens.com. Twitch integration is later: audience predicts via chat numbers (!1, !2, etc.), optional chat-voted sabotage where
the Impostor triggers and chat picks the effect, and the reward only triggers when the Impostor survives the full round. Not in the first version: proximity voice, real-money betting, full Twitch Extension, automatic subscriber queue, permanent sabotage penalties. The user's latest instruction is to just make it, with a nice design and Apple-looking UI/UX.
People need to
Create a room for a selected campus and receive a shareable invite link containing the room code.
Join an existing room by entering a display name and room code, or by opening an invite link that pre-fills the room code.
Join a room as an audience spectator without playing.
Lobby: show the room code, invite link, and the joined player list with host marked, and let the host start the round once the minimum player count is met.
Assign Crew or Impostor roles at round start and show each player their own role and, for impostors, their fellow impostors.
Move between campus locations along the campus map's connections, with the map showing the player's location, connected locations, player counts, and body markers.
Crew: start a task at its location, wait out the task time, then finish it, with crew task progress tracked toward the crew win condition.
Impostor: kill a player at the same location, and trigger temporary sabotage effects with a fix location and countdown.
Crew: fix an active sabotage at its fix location within the fix window.
Report a body found at the player's location to trigger a meeting.
Call an emergency meeting, limited per player.
Meeting: show who called it, list living players, let living players vote for a suspect or skip, tally votes, and eject on majority with ties or skips ejecting nobody.
Results: show the winning side and the full roster with roles and who is out, and let the host restart the room back to the lobby.
Audience: watch the full map of all players and bodies, and submit a Crew-or-Impostors prediction during the prediction window.
Show a live feed of round events, a round/meeting/sabotage countdown, and crew task progress.
Leave a room, with host passing to another player if the host leaves in the lobby.
Constraints
Up to 20 players per room; unlimited audience.
No accounts required to join.
In-memory rooms with no database; a server restart wipes all rooms.
Server owns all game state; the client polls for updates.
Invite tokens are short-lived.
Deployed on the same cPanel hosting as catinlens.com; frontend static files in public_html/game/, backend Node.js/Express in /home/catinlen/unilag-api/.
Design must be clean, serious, Apple-like: system fonts, dark background, subtle borders; no neon, no AI-themed visuals, no gacha effects; must match the existing catinlens.com look.
Not in the first version: proximity voice, real-money betting, full Twitch Extension, automatic subscriber queue, permanent sabotage penalties.
Twitch integration (chat-number predictions, chat-voted sabotage, survival reward) is later, not first version.
No sign-in
Backend
Custom interface
Who it's for
H
Host / Streamer
Creates a room for a chosen campus, shares the invite link, and starts the round once at least 5 players are in the lobby; may themselves be the Impostor. During the round they play their own role (crew tasks or impostor kills/sabotage), can call an emergency meeting, and after results can restart the room with Play again. Their success is a full round that runs to a winner and can be restarted for the next stream.
Uses
Landing
Lobby
Game
Meeting
Results
P
Player
Crew or Impostor
Joins a room by name or invite link with no account, then plays the round: moves between adjacent campus locations, starts and finishes tasks (crew), kills, sabotages and reports bodies (impostor), reports bodies, calls an emergency meeting, and votes during meetings. Their success is their side winning — all tasks done or impostors ejected for crew, or impostors surviving to the end for impostors.
Uses
Landing
Lobby
Game
Meeting
Results
A
Audience member / spectator
Joins a room as a spectator without playing, sees the full map of every player and body, and predicts who wins (Crew or Impostors) during the prediction window. Their success is a correct prediction when the Impostor survives the round, which is the reward hook for the Twitch stream.
Uses
Landing
Lobby
Game
Results
Pages
5 pages
Open to everyone
Landing
Lobby
Game
Meeting
Results
01
Public
Landing
Host creates a campus room and receives an invite link.
Anonymous first impression for CAMP-US that explains the campus social-deduction game and provides room creation, joining, and spectator entry.
H
Host / Streamer
P
Player
A
Audience member / spectator
02
Public
Lobby
Participants review the room and the host starts a valid round.
Room waiting area for the invite link, player roster, host controls, leaving, and round start readiness.
H
Host / Streamer
P
Player
A
Audience member / spectator
03
Public
Game
The game assigns hidden roles and privately represents each player's role information.
Live CAMP-US round workspace containing the campus map, role-specific play, tasks, movement, sabotage, local discoveries, audience observation, feed, progress, and countdowns.
H
Host / Streamer
P
Player
A
Audience member / spectator
04
Public
Meeting
Living players complete a timed meeting vote and the game resolves the tally.
Focused meeting destination for caller context, living-player voting, skip handling, tally resolution, and majority ejection.
H
Host / Streamer
P
Player
05
Public
Results
Participants review the completed round and the host can begin another round.
Round completion destination showing the winning side, revealed roster outcomes, and the host's restart path.
H
Host / Streamer
P
Player
A
Audience member / spectator
Sign-in
Not needed
No accounts needed
The accepted game explicitly requires no accounts and allows players and audience members to join rooms as guests. Room sessions and authoritative multiplayer state are transient server-owned game state, not private durable application identity or role-based account control.
Must be true first
Needs a backend
A multiplayer room service must create and hold authoritative room state.
The host must receive a shareable room code or invite link before others can join.
Players need a short-lived room session after joining so the server can associate actions with the correct participant.
The server must assign and protect hidden roles while exposing spectator and player views appropriate to each participant.
The room must retain live state through the lobby, round, meetings, results, and restart or departure flow.
Audience predictions must be accepted only during the defined prediction window.
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!