replit-com

byKazeem Isiaka (Otolorin_Cyber)

https://replit.com/join#t=ykbwmstneuzwcyn-otolorincyber2

No preview

Comments (0)

No comments yet. Be the first!

System Requirements

System Requirements Document for replit-com

1. Introduction

Project name: replit-com.

Replit project/join URL: https://replit.com/join#t=ykbwmstneuzwcyn-otolorincyber2

1.1 Core concept

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.

1.2 Name

CAMP-US

1.3 Source overview

CAMP-US / WHO BE THE FAKE? Project Record

Everything discussed and built in this conversation, as of 11 October 2026.

  1. The original design spec and my review

The spec (Web Social Deduction Architecture) asked for:

Page 1 of 96

1.4 Source overview

CAMP-US — Full Game Specification

1.5 Source overview

Claude finished the response

2. System Overview

Page 2 of 96

2.1 Source overview

</head> <body> <div id="msg" role="status"></div> <div id="entry" class="panel center" hidden> <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>

let sess = null, S = null, skew = 0, lastKey = '', busy = false, msgT = 0, nodes = [];

}

Page 3 of 96

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;
Page 4 of 96

2.2 Source overview

CAMP-US — Full Game Specification

2.3 Campuses

Each campus has its own map and locations. The rules stay the same.

  • UNILAG — Lagoon Front, Love Garden, Moremi Hall Garden, UBA Park, 2001 Cafeteria, Main Library, Sports Centre, halls (Jaja, Moremi, Mariere, Amina, Tinubu, Fagunwa)
  • OAU (Great Ife) — 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, New Market
  • University of Ibadan — Awba Dam, SUB, S. Bello Hall, Inde Hall, UI Zoo, Agric area
  • Babcock — Amphitheatre/Church, New Horizon, Campus Bakery, Stadium, BUTH Gate, The Walk, premium and regular halls

2.4 Source overview

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.

Page 5 of 96

2.5 Strategic System Blueprint

| 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. |

Page 6 of 96

2.6 Source overview

  1. The goal

• 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.

  1. The first backend engine (user's pasted file) and its bugs

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

  1. The playable multiplayer page (index.html, v3)
  2. The user's existing game: HIDE_AND_SEEK.html

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.

Page 7 of 96
  1. Comparison of the two projects

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.

2.7 Core concept

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.

Page 8 of 96

2.8 Source overview

.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>
Page 9 of 96
<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 ----

Page 10 of 96

2.9 Source overview

• 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.

  1. The first backend engine (user's pasted file) and its bugs

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:

  1. The playable multiplayer page (index.html, v3)

• 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.

  1. The campus map images
Page 11 of 96

• 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.

  1. Comparison of the two projects

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.

3. Functional Requirements

Page 12 of 96

3.1 Source overview

<h3>CAMP-US</h3>

<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.

Page 13 of 96

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();
Page 14 of 96
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();
Page 15 of 96
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) {

Page 16 of 96
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' },

Page 17 of 96
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
Page 18 of 96
    ? 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');
Page 19 of 96
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') {
Page 20 of 96
  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') {
Page 21 of 96
  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'));
Page 22 of 96
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';
Page 23 of 96
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);

Page 24 of 96

3.2 Players

  • Up to 20 players per room
  • Unlimited audience (spectators who can predict)
  • Host can be the Impostor
  • Roles: Crew or Impostor

3.3 Invite links

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.

3.4 Twitch integration (later)

  • Audience predicts via chat numbers (!1, !2, etc.)
  • Optional chat-voted sabotage (Impostor triggers, chat picks the effect)
  • Reward only triggers when Impostor survives the full round

3.5 Win condition that matters for the stream

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.

Page 25 of 96

3.6 Source overview

3.7 The "Addiction Matrix": Blending Freedom & Identity## 1. Weaponized Identity (Psychological Mechanics)

Instead of cosmetic items that just look cool, identity should be an active tool in the psychological warfare.

  • The Reputation Footprint: When a player joins a lobby, their "Deduction Passport" is displayed. It doesn't just show wins—it tracks behavioral tendencies (e.g., "Accuses early 70% of the time" or "Alibi verified by others 80% of the time"). This creates immediate, biased preconceptions before a single task is completed.
  • Archetype Perks: The identity roles chosen at the start (Detective, Puppeteer, Silent) shouldn't give game-breaking powers, but rather information filters.
  • The Detective might get an alert if two people are in a room together for too long.
    • The Puppeteer might get the ability to fake a mini-stat report to confuse the room.

3.8 High-Friction "Watercooler" Tasks

To keep the cognitive load high and players distracted, tasks shouldn't just be solitary puzzles. They should force players into vulnerable positions.

  • Dual-Auth Protocol: A task that requires two players to press a button at the exact same time on opposite sides of a room. This forces an immediate leap of faith. Do I trust this person enough to turn my back on them?
  • The "Spyware" Task: A web task where a player opens a mock browser terminal to upload files. While uploading, their screen gets slightly obscured, leaving them blind to their surroundings but perfectly tracking their target window.
Page 26 of 96

3.9 The "Courtroom Theater" Phase

When a body is found or a meeting is called, the web interface transforms into a dynamic debate floor designed to amplify peer pressure.

  • The Spotlight Mechanic: During voting, players can spend a limited "Clout" resource earned from previous rounds to force the spotlight onto someone. This mutes everyone else for 15 seconds, allowing that specific player to interrogate or defend themselves.
  • Micro-Expressions (Web Emotes): Since players are using text or proximity chat, give them visual reactive triggers. If someone is accused, they can hit a button to make their avatar "Sweat," "Slam the desk," or "Laugh nervously." This lets players inject actual human theater and bluffing into a standard web interface.
Page 27 of 96

3.10 Source overview

• 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.

Page 28 of 96

• 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.

Page 29 of 96

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.

Page 30 of 96

• 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).

Page 31 of 96

• 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.

Page 32 of 96

3.11 Source overview

</head> <body> <div id="msg" role="status"></div> <div id="entry" class="panel center" hidden> <h3>CAMP-US</h3>

<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; } },
Page 33 of 96
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 || '';
Page 34 of 96
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;
Page 35 of 96
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.');
Page 36 of 96
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) {

Page 37 of 96
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(
Page 38 of 96
  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;
Page 39 of 96
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(
Page 40 of 96
  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) {
Page 41 of 96
  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');
Page 42 of 96
    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) {
Page 43 of 96
  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;
Page 44 of 96
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;
Page 45 of 96
  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)' : '');
Page 46 of 96
});

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

3.12 Strategic System Blueprint

| 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. |

Page 47 of 96

3.13 Source overview

  1. The goal

• 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.

Page 48 of 96

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.

Page 49 of 96

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

Page 50 of 96

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.

  1. Blaming and meeting chat
Page 51 of 96

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.

Page 52 of 96

• 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.

3.14 What is not in the first version

  • Proximity voice
  • Real-money betting
  • Full Twitch Extension
  • Automatic subscriber queue
  • Permanent sabotage penalties

4. User Personas

4.1 Source overview

  H('div', { class: 'list' }, S.roster.map(p => H('div', {}, p.name + (p.isHost ? ' (host)' : '') + (me && p.id === me.id ? ' (you)' : '')))),
Page 53 of 96

4.2 Audience

  • Can join a room as spectators
  • Can predict who the Impostor is (using numbers for easy Twitch chat input)
  • If the Impostor survives to the end, correct predictors win something (channel points, shoutout, or vanity reward)
  • Streamer/host can be the Impostor

4.3 Players

  • Up to 20 players per room
  • Unlimited audience (spectators who can predict)
  • Host can be the Impostor
  • Roles: Crew or Impostor

5. Core User Flows

Page 54 of 96

5.1 Source overview

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;
Page 55 of 96
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>
Page 56 of 96

5.2 How a round works

  1. Host creates a room and shares an invite link.
  2. Players join with a name (or via the link).
  3. Host starts the round. Roles are assigned.
  4. Players move between campus locations and complete tasks.
  5. Impostor can sabotage (temporary effects).
  6. Anyone can call a meeting. Players vote. Majority ejects someone.
  7. Round ends when:
    • All tasks are done (Crew win)
    • Impostor is voted out (Crew win)
    • Impostor equals or outnumbers remaining Crew (Impostor win)
    • Timer runs out (Impostor win)
Page 57 of 96

5.3 Source overview

• Crowd voted sabotage through Twitch chat with a 30 second window.

• A subscriber priority queue that pushes join tokens to chat.

  1. Blaming and meeting 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.

5.4 Invite links

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.

Page 58 of 96

5.5 Twitch integration (later)

  • Audience predicts via chat numbers (!1, !2, etc.)
  • Optional chat-voted sabotage (Impostor triggers, chat picks the effect)
  • Reward only triggers when Impostor survives the full round
Page 59 of 96

5.6 Source overview

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>
Page 60 of 96
</body> </html>

5.7 Source overview

• Build the transparent stream overlay page for Larix (large text, transparent background, same audience view).

Page 61 of 96

6. Visuals Colors and Theme

[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.

Page 62 of 96

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.

Page 63 of 96

6.1 Source overview

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

}

  • { box-sizing: border-box; margin: 0; padding: 0; }

[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; }

Page 64 of 96

.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;
Page 65 of 96

}

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

  }
Page 66 of 96
  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);

}

}

6.2 Design

  • Clean, serious, Apple-like
  • System fonts, dark background, subtle borders
  • No neon, no AI-themed visuals, no gacha effects
  • Matches the existing catinlens.com look
  • Map shows the campus; sidebar shows players, tasks, feed, and audience poll
Page 67 of 96

6.3 Source overview

  1. The HTML mockups

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.

  1. The campus map images

• 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.

Page 68 of 96

6.4 Source overview

: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; }

Page 69 of 96

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

Page 70 of 96

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

}

}

Page 71 of 96

6.5 Source overview

  1. The HTML mockups

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.

Page 72 of 96

7. Signature Design Concept

[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.

Page 73 of 96

7.1 Source overview

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.

Page 74 of 96

8. Interaction Model & Motion Direction

[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.

Page 75 of 96

8.1 Source overview

if (window.ResizeObserver) new ResizeObserver(drawMap).observe(wrap);

else window.addEventListener('resize', drawMap);

9. Non-Functional Requirements

9.1 Source overview

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

Page 76 of 96

9.2 Source overview

  1. What Larix can and cannot do

• 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.

Page 77 of 96

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.

Page 78 of 96

9.3 Source overview

[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>
Page 79 of 96

9.4 Source overview

  1. What Larix can and cannot do

• 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.

Page 80 of 96

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.

10. Tech Stack

Page 81 of 96

10.1 Source overview

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' }));
Page 82 of 96
  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 = [];

10.2 Technical (realistic core)

  • Frontend: static HTML/CSS/JS in public_html/game/
  • Backend: Node.js/Express 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
Page 83 of 96

10.3 Source overview

  1. Domain and hosting

• 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.

  1. The fixed engine (room-engine.js, v2)

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.

  1. The API server (index.js, v2)
Page 84 of 96

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.

  1. Files and status

• 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.

Page 85 of 96

• 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.

10.4 Source overview

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);
Page 86 of 96

10.5 Source overview

• 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.

  1. The fixed engine (room-engine.js, v2)

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.

  1. The API server (index.js, v2)
Page 87 of 96

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.

11. Assumptions and Constraints

11.1 Source overview

Claude finished the response

Page 88 of 96

11.2 What is not in the first version

  • Proximity voice
  • Real-money betting
  • Full Twitch Extension
  • Automatic subscriber queue
  • Permanent sabotage penalties
Page 89 of 96

11.3 Source overview

• Setup: iPhone 12 running Larix Broadcaster, the game played on a second device, audience on Twitch.

• Domain: catinlens.com (see section 3).

• Audio: Larix uses one audio input at a time. A Bluetooth mic replaces the phone mic instead of adding to it, and Bluetooth mics drop to call quality. To get mic plus game sound, mix before the phone (small mixer, USB audio interface, or a wireless mic receiver) or stream through OBS.

• Charging: the iPhone 12 uses Lightning. Use Apple's Lightning to USB 3 Camera Adapter, with the charger (20W or more) in the Lightning port and USB audio gear in the USB port. Use a powered interface or hub so the phone battery is not drained. MagSafe wireless charging frees the port but charges slower and adds heat. A USB-C phone would use a hub with power pass-through instead.

• The user's original index.js imported ./campus-engine.js, a file that was not in the folder, so it would have crashed.

My criticisms:

• Scope is a small studio's project. Build a playable core with 6 to 8 players first.

• Wagers are fine with free points. Anything bought with real money runs into Twitch gambling policy and the law.

• Twitch Extensions need a backend service and review, cannot post a payload straight into chat, and chat cannot show panels. Subscriber checks need OAuth and linked accounts, which conflicts with zero account entry. Join tokens posted in chat are public.

Page 90 of 96

• 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.

  1. Twitch and audience ideas

• 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.

  1. Corrections to my own earlier answers

• 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.

  1. Open decisions and next steps
Page 91 of 96

• 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.

11.4 Superseded by explicit chat — Name

CAMP-US

Page 92 of 96

11.5 Source overview

• 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.

  1. Domain and hosting

• 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).

Page 93 of 96

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.

  1. Twitch and audience ideas

• 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.

  1. The user's existing game: HIDE_AND_SEEK.html

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.

  1. Files and status
Page 94 of 96

• 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.

  1. Corrections to my own earlier answers

• 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.

  1. Open decisions and next steps

• 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.

Page 95 of 96

12. Glossary

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.

Page 96 of 96

No completed page designs yet.

Completed design pages will appear here when they are ready to preview.

Spectator chooses to watch rather than play
Room is in lobby phase
Room is already in mission/meeting phase
Host starts the game
Prediction window open and audienceId present
Prediction window open and audienceId present
Sabotage is active
Spectator chooses to leave
Round ends phase becomes results
Spectator made a prediction earlier
Landing: Enter name and room code
Landing: Choose Watch to join as spectator
Landing: Submit watch request to join room as audience
Lobby: View lobby roster and room code while waiting as spectator
Game: View full campus map with all players, locations, and bodies spectator view
Game: Check if prediction window is open 90 seconds into round
Game: Submit prediction: Crew wins
Game: Submit prediction: Impostors win
Game: View sabotage alert panel with fix point and countdown
Game: View feed of round events
Game: Leave the game/room
Results: View round results and roster with roles revealed
Results: Check whether prediction was correct and Impostor survived reward condition
Results: Wait for host to restart no restart control for spectator

No completed page designs yet.

Completed design pages will appear here when they are ready to preview.

Spectator chooses to watch rather than play
Room is in lobby phase
Room is already in mission/meeting phase
Host starts the game
Prediction window open and audienceId present
Prediction window open and audienceId present
Sabotage is active
Spectator chooses to leave
Round ends phase becomes results
Spectator made a prediction earlier
Landing: Enter name and room code
Landing: Choose Watch to join as spectator
Landing: Submit watch request to join room as audience
Lobby: View lobby roster and room code while waiting as spectator
Game: View full campus map with all players, locations, and bodies spectator view
Game: Check if prediction window is open 90 seconds into round
Game: Submit prediction: Crew wins
Game: Submit prediction: Impostors win
Game: View sabotage alert panel with fix point and countdown
Game: View feed of round events
Game: Leave the game/room
Results: View round results and roster with roles revealed
Results: Check whether prediction was correct and Impostor survived reward condition
Results: Wait for host to restart no restart control for spectator