Two Keys: co-op mystery games for couples. Eight games on four engines (story, defuse, sync, labyrinth), two-device and couch play, mobile-first UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 22:36:12 +02:00
co-authored by Claude Opus 5.5
commit 0de6617c82
85 changed files with 15692 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#120c0e" />
<meta name="description" content="Co-op mystery games for couples. Two players, different clues, every lock needs both of you." />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&family=Playfair+Display:ital,wght@0,500;0,700;1,500&display=swap"
rel="stylesheet"
/>
<title>Two Keys · Co-op mysteries for couples</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@two-keys/client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit -p tsconfig.json && vite build",
"preview": "vite preview"
},
"dependencies": {
"@two-keys/shared": "*",
"framer-motion": "^11.11.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.27.0",
"remark-gfm": "^4.0.0",
"socket.io-client": "^4.8.0"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"vite": "^5.4.10"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#160c10"/><text x="32" y="44" font-size="34" text-anchor="middle">🔑</text></svg>

After

Width:  |  Height:  |  Size: 187 B

+24
View File
@@ -0,0 +1,24 @@
import { MotionConfig } from 'framer-motion';
import { Route, Routes } from 'react-router-dom';
import { Toasts } from './components/ui';
import { Home } from './pages/Home';
import { HowToPlay } from './pages/HowToPlay';
import { Library } from './pages/Library';
import { JoinRedirect, Room } from './pages/Room';
export function App() {
return (
// Animations switch off for anyone who asks their device for reduced motion.
<MotionConfig reducedMotion="user">
<Toasts />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/games" element={<Library />} />
<Route path="/how-to-play" element={<HowToPlay />} />
<Route path="/join/:code" element={<JoinRedirect />} />
<Route path="/room/:code" element={<Room />} />
<Route path="*" element={<Home />} />
</Routes>
</MotionConfig>
);
}
+173
View File
@@ -0,0 +1,173 @@
import { useEffect, useState } from 'react';
import type { ClueType, ClueView } from '@two-keys/shared';
import { Markdown } from './ui';
const TYPE_LABEL: Record<ClueType, string> = {
note: 'Note',
letter: 'Letter',
document: 'Document',
report: 'Report',
log: 'Log',
table: 'Table',
photo: 'Photo',
map: 'Map',
transcript: 'Transcript',
signal: 'Signal',
diagram: 'Diagram',
};
/** Who can see this clue: tinted in the viewer's role colour when it's theirs alone. */
function Audience({ clue, light }: { clue: ClueView; light: boolean }) {
const isPrivate = clue.audience === 'private';
return (
<span
className={`shrink-0 rounded-full px-2 py-0.5 font-sans text-[11px] font-semibold not-italic ${
isPrivate
? light
? 'bg-stone-900 text-[color:var(--role)]'
: 'bg-black/40 text-[color:var(--role)] ring-1 ring-[color:var(--role)]'
: light
? 'bg-stone-900/10 text-stone-700'
: 'bg-white/10 text-stone-300'
}`}
>
{isPrivate ? '👁 Only you can see this' : 'You both have this'}
</span>
);
}
function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?: boolean }) {
return (
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
<div className="min-w-0">
<div className={`text-[10px] font-bold uppercase tracking-[0.18em] opacity-60 ${mono ? 'font-mono' : 'font-sans'}`}>
{TYPE_LABEL[clue.type]}
</div>
<h4 className={`font-semibold leading-snug ${mono ? 'font-mono text-sm' : 'font-sans text-[15px]'}`}>{clue.title}</h4>
</div>
<Audience clue={clue} light={light} />
</div>
);
}
// `.` = 250ms on, `-` = 750ms on, 250ms between symbols, `/` = 1000ms between letters,
// then a 3000ms pause and it loops.
function signalTimeline(pattern: string): { on: boolean; ms: number }[] {
const steps: { on: boolean; ms: number }[] = [];
const symbols = [...pattern];
symbols.forEach((symbol, i) => {
if (symbol === '/') return;
steps.push({ on: true, ms: symbol === '-' ? 750 : 250 });
const next = symbols[i + 1];
if (next === undefined) steps.push({ on: false, ms: 3000 });
else steps.push({ on: false, ms: next === '/' ? 1000 : 250 });
});
return steps;
}
/** A blinking light. The pattern is only ever shown as light, never as text. */
export function SignalLamp({ pattern }: { pattern: string }) {
const [on, setOn] = useState(false);
const [run, setRun] = useState(0);
useEffect(() => {
const steps = signalTimeline(pattern);
if (steps.length === 0) return;
let timer: ReturnType<typeof setTimeout>;
let i = 0;
setOn(false);
const tick = () => {
const step = steps[i % steps.length];
setOn(step.on);
i++;
timer = setTimeout(tick, step.ms);
};
timer = setTimeout(tick, 600); // a beat of darkness so the start is unmistakable
return () => clearTimeout(timer);
}, [pattern, run]);
return (
<div className="flex flex-col items-center gap-4 py-3">
<div className="rounded-full border-4 border-stone-700 bg-black p-3">
<div role="img" aria-label="A blinking signal lamp" className={`lamp h-20 w-20 rounded-full ${on ? 'on' : ''}`} />
</div>
<button className="btn text-sm" onClick={() => setRun((n) => n + 1)}>
↻ Replay from the start
</button>
</div>
);
}
export function ClueCard({ clue }: { clue: ClueView }) {
switch (clue.type) {
case 'note':
return (
<article className="-rotate-[0.6deg] rounded-sm bg-[#f7edc5] p-4 text-stone-900 shadow-lg shadow-black/40">
<Header clue={clue} light />
<Markdown className="paper font-hand text-[1.4rem] leading-7">{clue.body}</Markdown>
</article>
);
case 'letter':
return (
<article className="rounded-sm border border-[#d8c9a3] bg-[#f3ead3] bg-[repeating-linear-gradient(0deg,transparent,transparent_27px,rgba(120,90,40,0.07)_28px)] p-5 text-stone-900 shadow-lg shadow-black/40">
<Header clue={clue} light />
<Markdown className="paper font-serif text-[1.05rem] leading-7">{clue.body}</Markdown>
</article>
);
case 'report':
case 'document':
case 'log':
return (
<article className="overflow-hidden rounded-md bg-[#ecebe4] text-stone-900 shadow-lg shadow-black/40">
<div className="bg-stone-800 px-4 py-1.5 font-mono text-[10px] font-bold uppercase tracking-[0.25em] text-stone-200">
{clue.type === 'log' ? '▮ Access log' : clue.type === 'report' ? '▮ Official report' : '▮ On file'}
</div>
<div className="p-4">
<Header clue={clue} light mono />
<Markdown className={`paper text-[15px] leading-6 ${clue.type === 'log' ? 'font-mono text-[13px]' : ''}`}>{clue.body}</Markdown>
</div>
</article>
);
case 'photo':
return (
<article className="rotate-[0.5deg] rounded-sm bg-stone-100 p-3 pb-4 text-stone-900 shadow-lg shadow-black/50">
<div className="flex min-h-[7rem] items-center rounded-sm bg-gradient-to-br from-stone-700 to-stone-900 p-4 text-stone-100">
<Markdown className="italic leading-6">{clue.body}</Markdown>
</div>
<div className="mt-3">
<Header clue={clue} light />
</div>
</article>
);
case 'map':
case 'diagram':
return (
<article className="rounded-md border border-accent/30 bg-black/50 p-4 text-accent">
<Header clue={clue} light={false} mono />
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
</article>
);
case 'transcript':
return (
<article className="rounded-md border-l-4 border-accent bg-white/[0.06] p-4">
<Header clue={clue} light={false} />
<Markdown className="font-serif text-[1.05rem] italic leading-7">{clue.body}</Markdown>
</article>
);
case 'signal':
return (
<article className="rounded-md border border-red-500/30 bg-black/60 p-4">
<Header clue={clue} light={false} mono />
{clue.signal && <SignalLamp pattern={clue.signal} />}
<Markdown className="text-sm leading-6 text-stone-300">{clue.body}</Markdown>
</article>
);
case 'table':
return (
<article className="rounded-md border border-white/15 bg-white/[0.05] p-4">
<Header clue={clue} light={false} mono />
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
</article>
);
}
}
+85
View File
@@ -0,0 +1,85 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { formatClock, type GameSummary, type PlayMode } from '@two-keys/shared';
import { getBest, saveSession } from '../lib/session';
import { request } from '../lib/socket';
import { CATEGORY_LABEL, difficultyKeys, themeVars } from '../lib/theme';
import { pushToast } from '../lib/toasts';
import { RoleBadge, Sheet } from './ui';
const ENGINE_BLURB: Record<GameSummary['engine'], string> = {
story: 'Split clues, shared locks',
defuse: 'One sees it, one has the manual',
sync: 'How well do you know each other?',
labyrinth: 'One map, one explorer',
};
/** A library card, themed with its game's own colours. Opens the "how do you want to play" sheet. */
export function GameCard({ game, className = '' }: { game: GameSummary; className?: string }) {
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const navigate = useNavigate();
const best = getBest(game.id);
const start = async (mode: PlayMode) => {
setCreating(true);
const res = await request('room:create', { gameId: game.id, mode });
setCreating(false);
if (!res.ok) return pushToast(res.message, 'error');
saveSession(res.code, res.playerToken);
navigate(`/room/${res.code}`);
};
return (
<div style={themeVars(game.theme)} className={className}>
<button
onClick={() => setOpen(true)}
className="group flex h-full w-full flex-col rounded-2xl border border-white/10 bg-bg bg-gradient-to-br from-primary/60 via-primary/15 to-transparent p-5 text-left shadow-lg shadow-black/30 transition hover:-translate-y-0.5 hover:border-accent/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<div className="flex flex-wrap items-center gap-2">
<span className="chip text-accent">{CATEGORY_LABEL[game.category]}</span>
<span className="text-xs text-stone-300">⏱ {game.estimatedMinutes} min</span>
<span className="text-xs" aria-label={`Difficulty ${game.difficulty} of 3`}>
{difficultyKeys(game.difficulty)}
<span className="opacity-25 grayscale">{difficultyKeys(3 - game.difficulty)}</span>
</span>
</div>
<h3 className="mt-3 font-display text-2xl font-bold leading-tight">{game.title}</h3>
<p className="mt-2 flex-1 text-sm leading-6 text-stone-300">{game.tagline}</p>
<div className="mt-4 flex items-center justify-between gap-2 text-xs text-stone-400">
<span>{best ? `🏆 Best ${formatClock(best.finalSeconds)} · ${best.rank}` : ENGINE_BLURB[game.engine]}</span>
<span className="font-semibold text-accent transition group-hover:translate-x-0.5">Play →</span>
</div>
</button>
<Sheet open={open} onClose={() => setOpen(false)} title={game.title}>
<p className="text-stone-300">{game.tagline}</p>
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{(['A', 'B'] as const).map((role) => (
<div key={role} className="card !p-3">
<RoleBadge role={role} name={game.roles[role].name} />
<p className="mt-2 text-sm text-stone-300">{game.roles[role].blurb}</p>
</div>
))}
</div>
<h4 className="mt-6 text-xs font-bold uppercase tracking-widest text-accent">How are you playing?</h4>
<div className="mt-2 grid gap-2">
<button className="btn-primary justify-start py-3 text-left" disabled={creating} onClick={() => start('duo')}>
<span className="text-2xl" aria-hidden>📱📱</span>
<span>
Two devices
<span className="block text-xs font-normal opacity-80">One phone each. You'll get a code to share.</span>
</span>
</button>
<button className="btn justify-start py-3 text-left" disabled={creating} onClick={() => start('couch')}>
<span className="text-2xl" aria-hidden>🛋️</span>
<span>
Couch mode
<span className="block text-xs font-normal opacity-80">One device, passed back and forth. No peeking.</span>
</span>
</button>
</div>
</Sheet>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from 'react';
import type { LockView } from '@two-keys/shared';
import type { Send } from '../lib/socket';
import { Sheet } from './ui';
/** Hints unlock in order, cost +2:00 each, and both players see them. */
export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
const [open, setOpen] = useState(false);
const [confirming, setConfirming] = useState(false);
const used = lock.hints.length;
const left = lock.hintsTotal - used;
const nextIsLast = used === lock.hintsTotal - 1;
const reveal = () => {
setConfirming(false);
send('hint:request', { lockId: lock.id });
};
return (
<>
<button
type="button"
className={`btn shrink-0 px-3 ${lock.nudge ? 'border-accent text-accent motion-safe:animate-pulse' : ''}`}
onClick={() => setOpen(true)}
aria-label={`Hints, ${used} of ${lock.hintsTotal} revealed`}
>
💡<span className="text-xs tabular-nums">{used}/{lock.hintsTotal}</span>
</button>
<Sheet open={open} onClose={() => { setOpen(false); setConfirming(false); }} title="Hints">
<p className="mb-4 text-sm text-stone-400">
Hints go from a gentle nudge to nearly the answer. Each one adds <strong className="text-stone-200">+2:00</strong> to
your time, and you both see it.
</p>
<ol className="space-y-2">
{lock.hints.map((hint, i) => (
<li key={i} className="card">
<div className="text-xs font-bold uppercase tracking-wider text-accent">Hint {i + 1}</div>
<p className="mt-1">{hint}</p>
</li>
))}
</ol>
{left === 0 ? (
<p className="mt-4 text-sm text-stone-400">That's every hint for this lock.</p>
) : confirming ? (
<div className="mt-4 rounded-2xl border border-accent/50 bg-accent/10 p-4">
<p className="font-semibold">This basically solves it. Sure?</p>
<div className="mt-3 flex gap-2">
<button className="btn-primary" onClick={reveal}>Yes, reveal it</button>
<button className="btn" onClick={() => setConfirming(false)}>Keep thinking</button>
</div>
</div>
) : (
<button className="btn-primary mt-4 w-full" onClick={() => (nextIsLast ? setConfirming(true) : reveal())}>
Reveal hint {used + 1} of {lock.hintsTotal} (+2:00)
</button>
)}
</Sheet>
</>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Sheet } from './ui';
/** Keys earned so far. A new key drops onto the ring with a little flourish. */
export function KeyRing({ keys, total }: { keys: string[]; total: number }) {
const [open, setOpen] = useState(false);
return (
<>
<button
className="flex min-h-[36px] items-center gap-1 rounded-full border border-accent/40 bg-black/30 px-2.5 py-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
onClick={() => setOpen(true)}
aria-label={`Key ring: ${keys.length} of ${total} keys`}
>
{/* A long ring collapses to one key and the count on narrow phones, leaving room for the partner's name. */}
{total > 3 && <span className="text-sm min-[440px]:hidden" aria-hidden>🔑</span>}
{Array.from({ length: total }, (_, i) => (
<span key={i} className={`relative h-5 w-4 text-sm leading-5 ${total > 3 ? 'hidden min-[440px]:inline-block' : 'inline-block'}`}>
<span className="opacity-20 grayscale" aria-hidden>🔑</span>
<AnimatePresence>
{i < keys.length && (
<motion.span
aria-hidden
className="absolute inset-0"
initial={{ y: -40, scale: 2.4, rotate: -90, opacity: 0 }}
animate={{ y: 0, scale: 1, rotate: 0, opacity: 1 }}
transition={{ type: 'spring', stiffness: 260, damping: 16 }}
>
🔑
</motion.span>
)}
</AnimatePresence>
</span>
))}
<span className="ml-1 text-xs font-semibold tabular-nums text-accent">
{keys.length}/{total}
</span>
</button>
<Sheet open={open} onClose={() => setOpen(false)} title="Key ring">
{keys.length === 0 ? (
<p className="text-stone-400">No keys yet. Every lock you open together earns one.</p>
) : (
<ul className="space-y-2">
{keys.map((name, i) => (
<li key={i} className="card flex items-center gap-3">
<span className="text-2xl" aria-hidden>🔑</span>
<span className="font-semibold">{name}</span>
</li>
))}
</ul>
)}
<p className="mt-4 text-sm text-stone-400">
{keys.length} of {total} keys collected.
</p>
</Sheet>
</>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useState, type FormEvent } from 'react';
import type { LockView } from '@two-keys/shared';
import { socket, type Send } from '../lib/socket';
import { HintButton } from './HintButton';
/** The shared lock: both players see it, either can submit. Only the server knows the answer. */
export function LockPanel({ lock, send }: { lock: LockView; send: Send }) {
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
const [wrong, setWrong] = useState(false);
const [shake, setShake] = useState(false);
useEffect(() => {
const onResult = ({ lockId, correct }: { lockId: string; correct: boolean }) => {
if (lockId !== lock.id) return;
setBusy(false);
setWrong(!correct);
if (!correct) setShake(true);
};
const onError = () => setBusy(false);
socket.on('lock:result', onResult);
socket.on('error', onError);
return () => {
socket.off('lock:result', onResult);
socket.off('error', onError);
};
}, [lock.id]);
const submit = (e: FormEvent) => {
e.preventDefault();
if (!value.trim() || busy) return;
setBusy(true);
setWrong(false);
send('lock:submit', { lockId: lock.id, value });
};
const numeric = lock.inputType === 'code' && /digit|number/i.test(lock.format ?? '');
return (
<section
aria-label="Lock"
className={`${lock.inputType === 'choice' ? '' : 'sticky bottom-0 z-10'} -mx-4 border-t border-accent/30 bg-bg/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur`}
>
<form onSubmit={submit} onAnimationEnd={() => setShake(false)} className={`mx-auto max-w-2xl ${shake ? 'shake' : ''}`}>
<label htmlFor={`lock-${lock.id}`} className="flex items-start gap-2 font-semibold leading-snug">
<span aria-hidden>🔒</span>
<span>{lock.prompt}</span>
</label>
{lock.inputType === 'choice' && lock.options ? (
<div role="radiogroup" aria-label={lock.prompt} className="mt-2 flex flex-wrap gap-2">
{lock.options.map((o) => (
<label
key={o.id}
className={`cursor-pointer rounded-xl border px-3 py-2 text-sm font-medium transition focus-within:ring-2 focus-within:ring-accent ${
value === o.id ? 'border-accent bg-accent/20' : 'border-white/20 bg-black/30 hover:bg-white/10'
}`}
>
<input
type="radio"
name={`lock-${lock.id}`}
className="sr-only"
checked={value === o.id}
onChange={() => setValue(o.id)}
/>
{o.label}
</label>
))}
</div>
) : null}
<div className="mt-2 flex gap-2">
{lock.inputType !== 'choice' && (
<input
id={`lock-${lock.id}`}
className={`field ${lock.inputType === 'code' ? 'font-mono tracking-[0.2em]' : ''}`}
value={value}
onChange={(e) => setValue(e.target.value)}
inputMode={numeric ? 'numeric' : 'text'}
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
maxLength={80}
placeholder={lock.format ?? 'Your answer'}
aria-describedby={`lock-${lock.id}-help`}
/>
)}
<button type="submit" className="btn-primary shrink-0" disabled={!value.trim() || busy}>
{busy ? '…' : 'Unlock'}
</button>
<HintButton lock={lock} send={send} />
</div>
<p id={`lock-${lock.id}-help`} className="mt-1.5 min-h-[1.25rem] text-xs text-stone-400" aria-live="polite">
{wrong ? (
<span className="font-semibold text-red-300">
Not quite. {lock.nudge ? 'Need a hint? 💡' : 'Compare notes and try again.'}
</span>
) : lock.nudge ? (
<span className="font-semibold text-accent">Stuck? Need a hint? 💡</span>
) : lock.format && lock.inputType !== 'choice' ? (
<>Format: {lock.format}</>
) : (
<>Either of you can answer. You both need to agree first!</>
)}
</p>
</form>
</section>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from 'react';
import { socket } from '../lib/socket';
import { Sheet } from './ui';
const DEBOUNCE_MS = 300;
/** A notepad both players share, synced live. Last write wins. */
export function Notepad({ text }: { text: string }) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(text);
const timer = useRef<ReturnType<typeof setTimeout>>();
const dirty = useRef(false);
// Take the partner's edits unless we have unsent typing of our own.
useEffect(() => {
if (!dirty.current) setDraft(text);
}, [text]);
const flush = (value: string) => {
clearTimeout(timer.current);
if (!dirty.current) return;
dirty.current = false;
socket.emit('notepad:update', { text: value });
};
const onChange = (value: string) => {
setDraft(value);
dirty.current = true;
clearTimeout(timer.current);
timer.current = setTimeout(() => flush(value), DEBOUNCE_MS);
};
return (
<>
<button
className="flex min-h-[36px] items-center gap-1 rounded-full border border-white/15 bg-black/30 px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
onClick={() => setOpen(true)}
aria-label="Open the shared notepad"
>
📝<span className="hidden sm:inline">Notes</span>
{text.trim() !== '' && <span className="h-1.5 w-1.5 rounded-full bg-accent" aria-hidden />}
</button>
<Sheet
open={open}
onClose={() => {
flush(draft);
setOpen(false);
}}
title="Shared notepad"
>
<p className="mb-2 text-sm text-stone-400">You both see this, live. Jot down times, names, half-baked theories.</p>
<textarea
className="field min-h-[40dvh] font-hand text-xl leading-7"
value={draft}
onChange={(e) => onChange(e.target.value)}
onBlur={() => flush(draft)}
maxLength={5000}
placeholder="Our notes…"
aria-label="Shared notes"
/>
</Sheet>
</>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { PING_EMOJIS, type Role, type RoomState } from '@two-keys/shared';
import { socket } from '../lib/socket';
import { ROLE_TEXT } from '../lib/theme';
/** Partner's online dot, what they're looking at, and the ping buttons. */
export function PartnerStatus({ state }: { state: RoomState }) {
const [open, setOpen] = useState(false);
const partner = state.partner;
const name = partner?.seat ? state.game.roles[partner.seat].name : 'Partner';
return (
<div className="flex min-w-0 items-center gap-1.5">
<button
className="flex min-h-[36px] min-w-0 items-center gap-1.5 rounded-full border border-white/15 bg-black/30 px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-label={`${name} is ${partner?.online ? 'online' : 'offline'}. Send a ping.`}
>
<span
aria-hidden
className={`h-2 w-2 shrink-0 rounded-full ${partner?.online ? 'bg-emerald-400 shadow-[0_0_6px_#34d399]' : 'bg-stone-500'}`}
/>
<span className={`truncate font-medium ${partner?.seat ? ROLE_TEXT[partner.seat] : ''}`}>{name}</span>
<span aria-hidden>👋</span>
</button>
{open && (
// Anchored to the header bar (not the button) so it always fits a narrow screen.
<div className="absolute inset-x-4 top-full z-30 mt-2 max-w-xs rounded-2xl border border-white/15 bg-bg p-3 shadow-2xl">
<p className="text-xs text-stone-400">
{partner?.online ? 'Online' : 'Offline. They can rejoin with the same link.'}
{partner?.online && partner.viewing ? ` · looking at ${partner.viewing}` : ''}
</p>
<div className="mt-2 flex gap-2">
{PING_EMOJIS.map((emoji) => (
<button
key={emoji}
className="btn flex-1 px-0 text-xl"
disabled={!partner?.online}
aria-label={`Send ${emoji}`}
onClick={() => {
socket.emit('ping', { emoji });
setOpen(false);
}}
>
{emoji}
</button>
))}
</div>
</div>
)}
</div>
);
}
interface Ping {
id: number;
emoji: string;
from: Role;
x: number;
}
/** Reactions from the partner float up the screen. */
export function PingOverlay({ roles }: { roles: RoomState['game']['roles'] }) {
const [pings, setPings] = useState<Ping[]>([]);
useEffect(() => {
let nextId = 1;
const onPing = ({ from, emoji }: { from: Role; emoji: string }) => {
const id = nextId++;
setPings((p) => [...p, { id, emoji, from, x: 15 + Math.random() * 70 }]);
setTimeout(() => setPings((p) => p.filter((x) => x.id !== id)), 2600);
};
socket.on('ping', onPing);
return () => {
socket.off('ping', onPing);
};
}, []);
return (
<div className="pointer-events-none fixed inset-0 z-50 overflow-hidden" aria-live="polite">
<AnimatePresence>
{pings.map((p) => (
<motion.div
key={p.id}
className="absolute bottom-24 flex flex-col items-center"
style={{ left: `${p.x}%` }}
initial={{ y: 0, opacity: 0, scale: 0.5 }}
animate={{ y: -260, opacity: 1, scale: 1.4 }}
exit={{ opacity: 0 }}
transition={{ duration: 2.2, ease: 'easeOut' }}
>
<span className="text-5xl">{p.emoji}</span>
<span className={`mt-1 rounded-full bg-black/70 px-2 text-[10px] font-semibold ${ROLE_TEXT[p.from]}`}>
{roles[p.from].name}
</span>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,28 @@
import type { Role } from '@two-keys/shared';
import { ROLE_TEXT } from '../lib/theme';
/** Couch mode: covers the screen while the device changes hands. */
export function PassDeviceScreen({
role,
roleName,
onReveal,
}: {
role: Role;
roleName: string;
onReveal: () => void;
}) {
return (
<div className="fixed inset-0 z-40 flex flex-col items-center justify-center gap-6 bg-bg px-6 text-center">
<div className="text-6xl" aria-hidden>
🙈
</div>
<h2 className="font-display text-3xl font-bold leading-tight">
Pass to <span className={ROLE_TEXT[role]}>{roleName}</span> 👀
</h2>
<p className="text-stone-400">(no peeking)</p>
<button className="btn-primary px-8 py-3 text-lg" onClick={onReveal} autoFocus>
I'm {roleName}. Tap to reveal
</button>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from 'react';
import { Link, NavLink } from 'react-router-dom';
const navClass = ({ isActive }: { isActive: boolean }) =>
`flex min-h-[44px] items-center rounded-lg px-3 text-sm font-medium transition hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
isActive ? 'text-accent' : 'text-stone-300'
}`;
export function SiteShell({ children }: { children: ReactNode }) {
return (
<div className="mx-auto flex min-h-dvh w-full max-w-5xl flex-col px-4">
<header className="flex items-center justify-between gap-2 py-2">
<Link to="/" className="flex min-h-[44px] items-center gap-1.5 font-display text-xl font-bold tracking-tight focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
Two Keys <span aria-hidden>🔑🔑</span>
</Link>
<nav className="flex items-center" aria-label="Main">
<NavLink to="/games" className={navClass}>Games</NavLink>
<NavLink to="/how-to-play" className={navClass}>How to play</NavLink>
</nav>
</header>
<main className="flex-1 pb-16">{children}</main>
<footer className="border-t border-white/10 py-6 text-center text-xs text-stone-500">
Two players. Different clues. Every lock needs both of you.
</footer>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { formatClock, type RoomState } from '@two-keys/shared';
import { useNow } from '../lib/hooks';
/** Elapsed time plus penalties. Runs locally between server updates. */
export function Timer({ timer, receivedAt }: { timer: RoomState['timer']; receivedAt: number }) {
const now = useNow(1000);
if (timer.startedAt === null) return null;
const serverNow = timer.serverNow + (now - receivedAt);
const elapsed = ((timer.finishedAt ?? serverNow) - timer.startedAt) / 1000;
return (
<div
className="flex items-baseline gap-1.5 font-mono tabular-nums"
role="timer"
aria-label={`Time ${formatClock(elapsed + timer.penaltySeconds)}`}
>
<span className="text-base font-bold">{formatClock(elapsed + timer.penaltySeconds)}</span>
{timer.penaltySeconds > 0 && (
<span className="text-[11px] text-red-300" title="Penalty time from hints and traps">
+{formatClock(timer.penaltySeconds)}
</span>
)}
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { Role } from '@two-keys/shared';
import { useToasts } from '../lib/toasts';
import { ROLE_BG, ROLE_BORDER, ROLE_TEXT } from '../lib/theme';
export function Markdown({ children, className = '' }: { children: string; className?: string }) {
return (
<div className={`md ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Wide tables scroll inside their card instead of stretching the page.
table: ({ node: _node, ...props }) => (
<div className="scroll-x">
<table {...props} />
</div>
),
}}
>
{children}
</ReactMarkdown>
</div>
);
}
export function RoleBadge({ role, name, className = '' }: { role: Role; name: string; className?: string }) {
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold ${ROLE_BORDER[role]} ${ROLE_BG[role]} ${ROLE_TEXT[role]} ${className}`}
>
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-current" />
{name}
</span>
);
}
function useEscape(onClose: () => void, active: boolean) {
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active, onClose]);
}
/** A panel that slides up from the bottom (a centred dialog on wide screens). */
export function Sheet({
open,
onClose,
title,
children,
}: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}) {
const panel = useRef<HTMLDivElement>(null);
useEscape(onClose, open);
useEffect(() => {
if (open) panel.current?.focus();
}, [open]);
// Rendered on <body>: the sticky bars use backdrop-filter, which would otherwise trap a fixed overlay inside them.
return createPortal(
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-40 flex items-end justify-center sm:items-center">
<motion.div
className="absolute inset-0 bg-black/70"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
ref={panel}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
className="relative flex max-h-[85dvh] w-full max-w-2xl flex-col rounded-t-3xl border border-white/15 bg-bg shadow-2xl outline-none sm:rounded-3xl"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'tween', duration: 0.25 }}
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-3">
<h2 className="font-display text-xl font-bold">{title}</h2>
<button className="btn !min-h-[36px] px-3 py-1 text-sm" onClick={onClose}>
Close
</button>
</div>
<div className="overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">{children}</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body,
);
}
export function Toasts() {
const toasts = useToasts();
return (
<div
aria-live="polite"
className="pointer-events-none fixed inset-x-0 top-3 z-50 flex flex-col items-center gap-2 px-4"
>
<AnimatePresence>
{toasts.map((t) => (
<motion.div
key={t.id}
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className={`max-w-sm rounded-xl border px-4 py-2 text-sm font-medium shadow-xl backdrop-blur ${
t.kind === 'error'
? 'border-red-400/40 bg-red-950/90 text-red-100'
: 'border-accent/40 bg-black/85 text-stone-100'
}`}
>
{t.message}
</motion.div>
))}
</AnimatePresence>
</div>
);
}
export function Spinner({ label }: { label: string }) {
return (
<div className="flex min-h-[60dvh] flex-col items-center justify-center gap-4 px-6 text-center text-stone-300">
<div className="text-4xl motion-safe:animate-pulse" aria-hidden>
🔑🔑
</div>
<p>{label}</p>
</div>
);
}
+292
View File
@@ -0,0 +1,292 @@
import type {
ButtonModuleView,
DefuseAction,
DefuseView,
KeypadModuleView,
RoomState,
TechnicianView,
WiresModuleView,
} from '@two-keys/shared';
import { Markdown } from '../components/ui';
import { useNow } from '../lib/hooks';
import type { Send } from '../lib/socket';
interface Props {
view: DefuseView;
state: RoomState;
receivedAt: number;
send: Send;
}
// Colours always come with their name as text, so nothing depends on telling hues apart.
const SWATCH: Record<string, string> = {
red: '#e5484d',
blue: '#3b82f6',
yellow: '#facc15',
green: '#22c55e',
white: '#f5f5f4',
};
const darkText = (color: string) => color === 'yellow' || color === 'white';
function timerText(remaining: number): string {
const t = Math.max(0, Math.ceil(remaining));
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`;
}
function useRemaining(view: DefuseView, receivedAt: number): number {
const now = useNow(200);
if (!view.clock) return view.level.timeSeconds;
if (!view.clock.running) return view.clock.remaining;
return Math.max(0, view.clock.remaining - ((now - receivedAt) / 1000) * view.clock.speed);
}
function StatusBar({ view, remaining }: { view: DefuseView; remaining: number }) {
const urgent = view.status === 'active' && remaining <= 30;
return (
<div className="sticky top-0 z-10 -mx-4 flex items-center justify-between gap-3 border-b border-white/10 bg-bg/95 px-4 py-2 backdrop-blur">
<div className="min-w-0">
<div className="text-[10px] font-bold uppercase tracking-widest text-accent">
Level {view.level.index + 1} of {view.level.total}
</div>
<div className="truncate font-display font-bold">{view.level.title}</div>
</div>
<div
className={`rounded-lg border-2 bg-black px-3 py-1 font-mono text-3xl font-bold tabular-nums ${
urgent ? 'border-red-500 text-red-400' : 'border-stone-700 text-red-500'
}`}
role="timer"
aria-label={`Bomb timer ${timerText(remaining)}`}
>
{timerText(remaining)}
</div>
<div className="text-right" aria-label={`${view.strikes} of ${view.maxStrikes} strikes`}>
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Strikes</div>
<div className="font-mono text-lg tracking-widest" aria-hidden>
{Array.from({ length: view.maxStrikes }, (_, i) => (
<span key={i} className={i < view.strikes ? 'text-red-500' : 'text-stone-600'}>
✖
</span>
))}
</div>
{view.clock && view.clock.speed > 1 && (
<div className="text-[10px] text-red-300">clock ×{view.clock.speed.toFixed(2)}</div>
)}
</div>
</div>
);
}
function ModuleFrame({ title, solved, children }: { title: string; solved: boolean; children: React.ReactNode }) {
return (
<section className={`rounded-2xl border-2 p-4 ${solved ? 'border-emerald-500/60 bg-emerald-500/10' : 'border-stone-600 bg-stone-900/70'}`}>
<div className="mb-3 flex items-center justify-between">
<h3 className="font-mono text-sm font-bold uppercase tracking-widest">{title}</h3>
<span className={`flex items-center gap-1.5 text-xs font-bold ${solved ? 'text-emerald-400' : 'text-stone-500'}`}>
<span className={`h-2.5 w-2.5 rounded-full ${solved ? 'bg-emerald-400' : 'bg-stone-600'}`} aria-hidden />
{solved ? 'DISARMED' : 'ARMED'}
</span>
</div>
{children}
</section>
);
}
function Wires({ module, act }: { module: WiresModuleView; act: (a: DefuseAction) => void }) {
return (
<ModuleFrame title="Wires" solved={module.solved}>
<ol className="space-y-2">
{module.wires.map((color, i) => (
<li key={i} className="flex items-center gap-2">
<span className="w-5 font-mono text-sm text-stone-400">{i + 1}</span>
<span className="h-3 flex-1 rounded-full shadow-inner" style={{ backgroundColor: SWATCH[color] ?? color }} aria-hidden />
<span className="w-16 font-mono text-sm font-bold uppercase">{color}</span>
<button
className="btn !min-h-[40px] px-3 text-sm"
disabled={module.solved}
onClick={() => act({ type: 'cut', index: i + 1 })}
aria-label={`Cut wire ${i + 1}, ${color}`}
>
✂ Cut
</button>
</li>
))}
</ol>
</ModuleFrame>
);
}
function BigButton({ module, act }: { module: ButtonModuleView; act: (a: DefuseAction) => void }) {
const bg = SWATCH[module.color] ?? module.color;
return (
<ModuleFrame title="The Button" solved={module.solved}>
<div className="flex items-center gap-4">
<div
className={`flex h-28 w-28 shrink-0 flex-col items-center justify-center rounded-full border-4 border-black/40 font-mono shadow-xl ${
module.holding ? 'scale-95 shadow-inner' : ''
} ${darkText(module.color) ? 'text-stone-900' : 'text-white'}`}
style={{ backgroundColor: bg }}
aria-label={`A ${module.color} button labelled ${module.label}`}
role="img"
>
<span className="text-xl font-bold">{module.label}</span>
<span className="text-[10px] font-bold uppercase opacity-80">{module.color} button</span>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{module.holding ? (
<>
<div
className={`rounded-lg px-3 py-2 text-center font-mono text-sm font-bold uppercase ${darkText(module.strip ?? '') ? 'text-stone-900' : 'text-white'}`}
style={{ backgroundColor: SWATCH[module.strip ?? ''] ?? '#444' }}
aria-live="assertive"
>
Strip: {module.strip}
</div>
<button className="btn-primary" onClick={() => act({ type: 'release' })}>
Release now
</button>
</>
) : (
<>
<button className="btn" disabled={module.solved} onClick={() => act({ type: 'tap' })}>
Tap it
</button>
<button className="btn" disabled={module.solved} onClick={() => act({ type: 'hold' })}>
Press &amp; hold
</button>
</>
)}
</div>
</div>
</ModuleFrame>
);
}
function Keypad({ module, act }: { module: KeypadModuleView; act: (a: DefuseAction) => void }) {
return (
<ModuleFrame title="Hearts & Symbols" solved={module.solved}>
<div className="grid grid-cols-2 gap-2">
{module.symbols.map((s) => {
const [glyph, ...name] = s.label.split(' ');
return (
<button
key={s.id}
className={`flex flex-col items-center rounded-xl border-2 px-2 py-3 transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
s.pressed ? 'border-emerald-500 bg-emerald-500/20' : 'border-stone-500 bg-stone-800 hover:bg-stone-700'
}`}
disabled={s.pressed || module.solved}
onClick={() => act({ type: 'press', symbol: s.id })}
aria-label={`${name.join(' ')}${s.pressed ? ', pressed' : ''}`}
>
<span className="text-3xl" aria-hidden>{glyph}</span>
<span className="mt-1 text-xs font-semibold">{name.join(' ')}{s.pressed ? ' ✓' : ''}</span>
</button>
);
})}
</div>
</ModuleFrame>
);
}
function Device({ view, send }: { view: TechnicianView; send: Send }) {
if (!view.device) return null;
const act = (moduleId: string) => (action: DefuseAction) => send('defuse:action', { moduleId, action });
const { serial, batteries, modules } = view.device;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 rounded-2xl border-2 border-stone-600 bg-stone-900/70 p-4 font-mono">
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Serial number</div>
<div className="text-2xl font-bold tracking-[0.15em]">{serial}</div>
</div>
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Batteries</div>
<div className="text-2xl font-bold">
{batteries} <span className="text-base" aria-hidden>{'🔋'.repeat(batteries)}</span>
</div>
</div>
</div>
{modules.map((m) =>
m.id === 'wires' ? (
<Wires key={m.id} module={m} act={act(m.id)} />
) : m.id === 'button' ? (
<BigButton key={m.id} module={m} act={act(m.id)} />
) : (
<Keypad key={m.id} module={m} act={act(m.id)} />
),
)}
</div>
);
}
export function DefuseGame({ view, state, receivedAt, send }: Props) {
const remaining = useRemaining(view, receivedAt);
const technician = view.seat === 'technician';
const partner = state.game.roles[technician ? 'B' : 'A'].name;
let banner = null;
if (view.status === 'ready') {
banner = (
<div className="card text-center">
<div className="text-5xl" aria-hidden>💣</div>
<h2 className="mt-2 font-display text-2xl font-bold">
Level {view.level.index + 1}: {view.level.title}
</h2>
<p className="mt-1 text-stone-300">
{timerText(view.level.timeSeconds)} on the clock. {view.maxStrikes} strikes and it's over.
</p>
<p className="mt-3 text-sm text-stone-400">
{technician
? `You'll see the device. Describe everything to the ${partner}: serial number, batteries, every colour and label.`
: `You have the manual below. Ask the ${partner} for the serial number and battery count first.`}
</p>
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
Start the clock ⏱
</button>
</div>
);
} else if (view.status === 'exploded') {
banner = (
<div className="rounded-2xl border-2 border-red-500/70 bg-red-950/60 p-5 text-center" aria-live="assertive">
<div className="text-5xl" aria-hidden>💥</div>
<h2 className="mt-2 font-display text-2xl font-bold">Boom.</h2>
<p className="mt-1 text-stone-200">
{view.failReason === 'time' ? 'The clock ran out.' : 'Three strikes.'} Good thing it was only confetti.
</p>
<p className="mt-1 text-sm text-stone-400">You keep your keys. A fresh device is waiting.</p>
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
Try again with a new device
</button>
</div>
);
} else if (view.status === 'cleared') {
banner = (
<div className="rounded-2xl border-2 border-emerald-500/70 bg-emerald-950/50 p-5 text-center" aria-live="polite">
<div className="text-5xl" aria-hidden>✅</div>
<h2 className="mt-2 font-display text-2xl font-bold">Disarmed with {timerText(remaining)} to spare!</h2>
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
Next level →
</button>
</div>
);
}
return (
<div className="space-y-4">
<StatusBar view={view} remaining={remaining} />
{banner}
{view.seat === 'technician' && view.status !== 'ready' && (
<div className={view.status === 'active' ? '' : 'pointer-events-none opacity-50'}>
<Device view={view} send={send} />
</div>
)}
{view.seat === 'technician' && view.status === 'active' && (
<p className="text-center text-sm text-stone-400">You can't see the manual. Describe what you see out loud.</p>
)}
{view.seat === 'expert' && (
<article className="rounded-md bg-[#ecebe4] p-5 text-stone-900 shadow-lg">
<Markdown className="paper leading-7">{view.manual}</Markdown>
</article>
)}
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
import { useEffect } from 'react';
import type { Dir, ExplorerView, LabyrinthView, NavigatorView, RoomState } from '@two-keys/shared';
import type { Send } from '../lib/socket';
interface Props {
view: LabyrinthView;
state: RoomState;
send: Send;
}
/** "☀ A carved sun (the entrance)" → ["☀", "A carved sun (the entrance)"] */
const splitLabel = (label: string): [string, string] => {
const i = label.indexOf(' ');
return i === -1 ? [label, ''] : [label.slice(0, i), label.slice(i + 1)];
};
function Lanterns({ carried, total }: { carried: number; total: number }) {
return (
<div className="flex items-center gap-1" aria-label={`${carried} of ${total} lanterns carried`}>
{Array.from({ length: total }, (_, i) => (
<span key={i} aria-hidden className={`text-2xl ${i < carried ? '' : 'opacity-25 grayscale'}`}>
🏮
</span>
))}
</div>
);
}
// ---------- Navigator: the whole map, but not where the Explorer is ----------
function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerName: string }) {
const width = view.grid[0].length;
const glyphAt = new Map(view.glyphs.map((g) => [`${g.x},${g.y}`, g]));
const lanternAt = new Map(view.lanterns.map((l) => [`${l.x},${l.y}`, l]));
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<Lanterns carried={view.keysCarried} total={view.totalKeys} />
<div className="text-right text-sm text-stone-300">
{view.moves !== null && <div>{view.moves} moves</div>}
{view.falls > 0 && <div className="text-red-300">{view.falls} trap{view.falls === 1 ? '' : 's'} sprung</div>}
</div>
</div>
<div
className="mx-auto grid w-full max-w-md gap-px overflow-hidden rounded-lg border-2 border-accent/40 bg-black/60 p-1"
style={{ gridTemplateColumns: `repeat(${width}, minmax(0, 1fr))` }}
role="img"
aria-label="Map of the labyrinth. The legend below lists every glyph, lantern, trap and the exit."
>
{view.grid.flatMap((row, y) =>
[...row].map((cell, x) => {
const key = `${x},${y}`;
const glyph = glyphAt.get(key);
const lantern = lanternAt.get(key);
let content = '';
let tone = 'bg-stone-800/40';
if (cell === '#') tone = 'bg-stone-700';
else if (cell === 'T') { content = '☠'; tone = 'bg-red-950 text-red-400'; }
else if (cell === 'E') { content = '🚪'; tone = 'bg-emerald-950'; }
else if (lantern) { content = '🏮'; tone = lantern.collected ? 'bg-stone-800/40 opacity-30 grayscale' : 'bg-amber-950'; }
else if (glyph) content = splitLabel(glyph.label)[0];
return (
<div key={key} className={`flex aspect-square items-center justify-center text-[clamp(11px,4vw,20px)] leading-none ${tone}`}>
{content}
</div>
);
}),
)}
</div>
<p className="text-center text-sm text-stone-400">
You can't see {explorerName}. Ask what's on the floor and which ways are open, then work out where they are.
</p>
<div className="card">
<h3 className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">Map legend</h3>
<ul className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-2">
{view.glyphs.map((g) => {
const [symbol, text] = splitLabel(g.label);
return (
<li key={`${g.x},${g.y}`} className="flex gap-2">
<span className="w-6 text-center" aria-hidden>{symbol}</span>
{text}
</li>
);
})}
{view.lanterns.map((l) => (
<li key={l.name} className={`flex gap-2 ${l.collected ? 'text-stone-500 line-through' : ''}`}>
<span className="w-6 text-center" aria-hidden>🏮</span>
{l.name}
{l.collected && <span className="no-underline"> ✓</span>}
</li>
))}
<li className="flex gap-2 text-red-300"><span className="w-6 text-center" aria-hidden>☠</span>Trap. They can't see it!</li>
<li className="flex gap-2"><span className="w-6 text-center" aria-hidden>🚪</span>Exit (needs all {view.totalKeys} lanterns)</li>
<li className="flex gap-2 text-stone-400"><span className="w-6 text-center" aria-hidden>▪</span>Most floor is bare stone</li>
</ul>
</div>
</div>
);
}
// ---------- Explorer: one cell, four directions ----------
const DIRS: { dir: Dir; label: string; arrow: string; area: string; keys: string[] }[] = [
{ dir: 'n', label: 'North', arrow: '▲', area: 'col-start-2 row-start-1', keys: ['ArrowUp', 'w', 'W'] },
{ dir: 'w', label: 'West', arrow: '◀', area: 'col-start-1 row-start-2', keys: ['ArrowLeft', 'a', 'A'] },
{ dir: 'e', label: 'East', arrow: '▶', area: 'col-start-3 row-start-2', keys: ['ArrowRight', 'd', 'D'] },
{ dir: 's', label: 'South', arrow: '▼', area: 'col-start-2 row-start-3', keys: ['ArrowDown', 's', 'S'] },
];
function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send: Send; navigatorName: string }) {
// Arrow keys / WASD move too (ignored while typing in the notepad).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && ['INPUT', 'TEXTAREA'].includes(target.tagName)) return;
const match = DIRS.find((d) => d.keys.includes(e.key));
if (!match || !view.open[match.dir]) return;
e.preventDefault();
send('maze:move', { dir: match.dir });
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [view.open, send]);
const [symbol, text] = view.glyphLabel ? splitLabel(view.glyphLabel) : ['', ''];
const open = DIRS.filter((d) => view.open[d.dir]).map((d) => d.label.toLowerCase());
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<Lanterns carried={view.keysCarried} total={view.totalKeys} />
<div className="text-sm text-stone-300">{view.moves} moves</div>
</div>
<div
className="rounded-2xl border border-accent/30 bg-[radial-gradient(circle_at_center,rgb(var(--accent)/0.22),transparent_70%)] p-6 text-center"
aria-live="polite"
>
<div className="text-xs font-bold uppercase tracking-widest text-stone-400">Under your feet</div>
{view.glyphLabel ? (
<>
<div className="mt-2 text-7xl" aria-hidden>{symbol}</div>
<p className="mt-2 font-display text-2xl font-bold">{text}</p>
</>
) : (
<div className="mt-2 text-6xl opacity-60" aria-hidden>🕯️</div>
)}
{view.message && <p className="mt-3 text-lg italic text-stone-200">{view.message}</p>}
<p className="mt-3 text-sm text-stone-400">
Open passages: <strong className="text-stone-200">{open.join(', ') || 'none'}</strong>
</p>
</div>
<div className="mx-auto grid w-56 grid-cols-3 grid-rows-3 gap-2" role="group" aria-label="Move">
{DIRS.map((d) => (
<button
key={d.dir}
className={`btn aspect-square text-2xl ${d.area}`}
disabled={!view.open[d.dir]}
onClick={() => send('maze:move', { dir: d.dir })}
aria-label={`Go ${d.label.toLowerCase()}${view.open[d.dir] ? '' : ' (wall)'}`}
>
{d.arrow}
</button>
))}
<div className="col-start-2 row-start-2 flex items-center justify-center text-3xl" aria-hidden>🧭</div>
</div>
<p className="text-center text-sm text-stone-400">
Tell {navigatorName} what's on the floor and which ways are open. There are traps you can't see. Wait for directions!
</p>
</div>
);
}
export function LabyrinthGame({ view, state, send }: Props) {
return view.seat === 'navigator' ? (
<NavigatorMap view={view} explorerName={state.game.roles.B.name} />
) : (
<ExplorerCell view={view} send={send} navigatorName={state.game.roles.A.name} />
);
}
+262
View File
@@ -0,0 +1,262 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import type { AccusationView, ChapterView, ClueView, LockView, Role, RoomState, StoryView } from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
import { ClueCard } from '../components/ClueCard';
import { LockPanel } from '../components/LockPanel';
import { Markdown, Sheet } from '../components/ui';
import { socket, type Send } from '../lib/socket';
interface Props {
view: StoryView;
state: RoomState;
role: Role;
send: Send;
}
function Clues({ clues }: { clues: ClueView[] }) {
return (
<div className="space-y-5">
{clues.map((clue) => (
<ClueCard key={clue.id} clue={clue} />
))}
</div>
);
}
/** The lock springs open, the key drops onto the ring, either player continues. */
function Unlocked({ lock, onContinue, last }: { lock: LockView; onContinue: () => void; last: boolean }) {
return (
<motion.section
className="rounded-2xl border border-accent/50 bg-accent/10 p-5"
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
aria-live="polite"
>
<div className="flex items-center gap-3">
<motion.span
className="text-4xl"
aria-hidden
initial={{ rotate: -30, scale: 0.4 }}
animate={{ rotate: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 10 }}
>
🔓
</motion.span>
<div>
<div className="text-xs font-bold uppercase tracking-widest text-accent">Unlocked</div>
{lock.keyName && <div className="font-display text-xl font-bold">🔑 {lock.keyName}</div>}
</div>
</div>
{lock.successText && <Markdown className="mt-4 leading-7">{lock.successText}</Markdown>}
<button className="btn-primary mt-5 w-full" onClick={onContinue}>
{last ? 'On to the finale →' : 'Continue →'}
</button>
</motion.section>
);
}
/** Both players must agree: one proposes, the other agrees or changes it. */
function Accusation({ finale, role, state, send }: { finale: AccusationView; role: Role; state: RoomState; send: Send }) {
const proposalKey = JSON.stringify(finale.proposal?.answers ?? null);
const [draft, setDraft] = useState<Record<string, string>>(finale.proposal?.answers ?? {});
// A new proposal (from either side) replaces whatever was being picked.
useEffect(() => {
if (finale.proposal) setDraft(finale.proposal.answers);
}, [proposalKey]); // eslint-disable-line react-hooks/exhaustive-deps
const complete = finale.questions.every((q) => draft[q.id]);
const matchesProposal =
finale.proposal !== null && finale.questions.every((q) => draft[q.id] === finale.proposal!.answers[q.id]);
const partnerName = state.game.roles[otherRole(role)].name;
const mine = finale.proposal?.by === role;
let action;
if (matchesProposal && finale.agreed) {
action = (
<button className="btn-primary w-full" onClick={() => send('finale:submit')}>
You both agree. Make the accusation ⚖️
</button>
);
} else if (matchesProposal && mine) {
action = (
<button className="btn w-full" disabled>
Waiting for {partnerName} to agree…
</button>
);
} else if (matchesProposal) {
action = (
<div className="space-y-2">
<button className="btn-primary w-full" onClick={() => send('finale:agree')}>
Agree with {partnerName} ✓
</button>
<p className="text-center text-xs text-stone-400">Disagree? Change any answer above to propose your own.</p>
</div>
);
} else {
action = (
<button className="btn-primary w-full" disabled={!complete} onClick={() => send('finale:propose', { answers: draft })}>
{finale.proposal ? 'Propose this instead' : 'Propose this accusation'}
</button>
);
}
return (
<section className="space-y-5">
{finale.questions.map((q) => (
<fieldset key={q.id} className="card">
<legend className="float-left mb-3 w-full font-display text-xl font-bold">{q.label}</legend>
<div className="clear-both space-y-2">
{q.options.map((o) => {
const picked = draft[q.id] === o.id;
const proposed = finale.proposal?.answers[q.id] === o.id;
return (
<label
key={o.id}
className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2.5 transition focus-within:ring-2 focus-within:ring-accent ${
picked ? 'border-accent bg-accent/20' : 'border-white/15 bg-black/20 hover:bg-white/10'
}`}
>
<input
type="radio"
name={q.id}
className="h-4 w-4 accent-[rgb(var(--accent))]"
checked={picked}
onChange={() => setDraft((d) => ({ ...d, [q.id]: o.id }))}
/>
<span className="flex-1">{o.label}</span>
{proposed && !picked && <span className="chip">proposed</span>}
</label>
);
})}
</div>
</fieldset>
))}
<div className="sticky bottom-0 -mx-4 border-t border-accent/30 bg-bg/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur">
<p className="mb-2 text-center text-xs text-stone-400" aria-live="polite">
{finale.attemptsLeft} of {finale.maxAttempts} attempts left
{finale.proposal && !finale.agreed && ` · ${mine ? 'You' : partnerName} proposed an accusation`}
</p>
{action}
</div>
</section>
);
}
function EvidenceLocker({ view, chapters }: { view: StoryView; chapters: ChapterView[] }) {
return (
<div className="space-y-6">
{view.suspects.length > 0 && (
<section>
<h3 className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">Suspects</h3>
<ul className="space-y-2">
{view.suspects.map((s) => (
<li key={s.id} className="card !p-3">
<div className="font-semibold">
{s.name} <span className="font-normal text-stone-400">· {s.role}</span>
</div>
<p className="mt-0.5 text-sm text-stone-300">{s.description}</p>
</li>
))}
</ul>
</section>
)}
{chapters.length === 0 && view.suspects.length === 0 && (
<p className="text-stone-400">Clues from earlier chapters will collect here.</p>
)}
{chapters.map((ch, i) => (
<details key={ch.id} className="group" open={i === chapters.length - 1}>
<summary className="cursor-pointer list-none rounded-xl bg-white/5 px-3 py-2.5 font-semibold marker:hidden">
<span className="mr-2 inline-block transition group-open:rotate-90" aria-hidden>▸</span>
Chapter {i + 1}: {ch.title}
{ch.lock.keyName && <span className="ml-2 text-sm font-normal text-accent">🔑 {ch.lock.keyName}</span>}
</summary>
<div className="mt-4 space-y-4 px-1">
{ch.lock.successText && <Markdown className="rounded-xl bg-accent/10 p-3 text-sm leading-6">{ch.lock.successText}</Markdown>}
<Clues clues={ch.clues} />
</div>
</details>
))}
</div>
);
}
export function StoryGame({ view, state, role, send }: Props) {
const [lockerOpen, setLockerOpen] = useState(false);
const atFinale = view.finale !== null;
const chapter = atFinale ? null : view.chapters[view.currentChapter];
const stageLabel = atFinale ? 'the finale' : `chapter ${view.currentChapter + 1}`;
// Everything already solved: all chapters once the finale opens, earlier ones before that.
const past = atFinale ? view.chapters : view.chapters.slice(0, view.currentChapter);
// Tell the partner what we're looking at.
useEffect(() => {
if (state.mode === 'duo') socket.emit('presence:view', { label: lockerOpen ? 'the evidence locker' : stageLabel });
}, [lockerOpen, stageLabel, state.mode]);
// Braces matter: newer browsers return a promise from scrollTo, and an effect must not return one.
useEffect(() => {
window.scrollTo({ top: 0 });
}, [view.currentChapter, atFinale]);
const heading = (
<div>
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold uppercase tracking-widest text-accent">
{atFinale ? 'Finale' : `Chapter ${view.currentChapter + 1} of ${view.totalChapters}`}
</div>
<button className="btn shrink-0 !min-h-[40px] px-3 py-1 text-sm" onClick={() => setLockerOpen(true)}>
🗄️ Evidence{past.length > 0 ? ` (${past.reduce((n, c) => n + c.clues.length, 0)})` : ''}
</button>
</div>
<div className="mt-1">
<h2 className="font-display text-3xl font-bold leading-tight">
{chapter ? chapter.title : view.finale?.type === 'accusation' ? 'The Accusation' : 'The Way Out'}
</h2>
</div>
</div>
);
return (
<div className="space-y-5">
{heading}
{chapter && (
<>
<Markdown className="text-lg leading-8 text-stone-200">{chapter.narrative}</Markdown>
<Clues clues={chapter.clues} />
{chapter.lock.solved && view.awaitingContinue ? (
<Unlocked
lock={chapter.lock}
last={view.currentChapter === view.totalChapters - 1}
onContinue={() => send('chapter:continue')}
/>
) : (
<LockPanel key={chapter.lock.id} lock={chapter.lock} send={send} />
)}
</>
)}
{view.finale?.type === 'accusation' && (
<>
<Markdown className="text-lg leading-8 text-stone-200">{view.finale.prompt}</Markdown>
<Accusation finale={view.finale} role={role} state={state} send={send} />
</>
)}
{view.finale?.type === 'escape' && (
<>
<Markdown className="text-lg leading-8 text-stone-200">{view.finale.prompt}</Markdown>
<Clues clues={view.finale.clues} />
<LockPanel key={view.finale.lock.id} lock={view.finale.lock} send={send} />
</>
)}
<Sheet open={lockerOpen} onClose={() => setLockerOpen(false)} title="Evidence Locker">
<EvidenceLocker view={view} chapters={past} />
</Sheet>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { motion } from 'framer-motion';
import type { Role, RoomState, SyncView } from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
import { RoleBadge } from '../components/ui';
import type { Send } from '../lib/socket';
interface Props {
view: SyncView;
state: RoomState;
role: Role;
send: Send;
}
function Marks({ view }: { view: SyncView }) {
return (
<div className="flex items-center justify-center gap-2" aria-label={`${view.points} matches so far, ${view.pointsForKey} needed for the key`}>
{Array.from({ length: view.questionsPerRound }, (_, i) => (
<span
key={i}
aria-hidden
className={`flex h-7 w-7 items-center justify-center rounded-full border text-sm ${
view.marks[i] === true
? 'border-accent bg-accent text-stone-950'
: view.marks[i] === false
? 'border-white/20 bg-white/5 text-stone-500'
: 'border-white/15 text-transparent'
}`}
>
{view.marks[i] === true ? '♥' : view.marks[i] === false ? '✕' : '·'}
</span>
))}
</div>
);
}
/** One answer card that flips over when both answers are in. */
function FlipCard({ role, name, answer, delay }: { role: Role; name: string; answer: string; delay: number }) {
return (
<div className="flex-1 [perspective:800px]">
<motion.div
className="card flex min-h-[7rem] flex-col items-center justify-center gap-2 text-center"
initial={{ rotateY: 180, opacity: 0.4 }}
animate={{ rotateY: 0, opacity: 1 }}
transition={{ duration: 0.6, delay }}
>
<RoleBadge role={role} name={name} />
<div className="font-display text-lg font-bold leading-snug">{answer}</div>
</motion.div>
</div>
);
}
export function SyncGame({ view, state, role, send }: Props) {
const roles = state.game.roles;
const partnerName = roles[otherRole(role)].name;
const couch = state.mode === 'couch';
const header = (
<div className="text-center">
<div className="text-xs font-bold uppercase tracking-widest text-accent">
Round {view.round.index + 1} of {view.round.total}
{view.isReplay && ' · Replay'}
</div>
<h2 className="font-display text-3xl font-bold">{view.round.title}</h2>
</div>
);
if (view.status === 'intro') {
return (
<div className="space-y-5 text-center">
{header}
<p className="text-lg text-stone-200">{view.round.blurb}</p>
<p className="text-sm text-stone-400">
{view.questionsPerRound} questions. Match on {view.pointsForKey} to earn the key. Answer privately, no peeking.
</p>
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
We're ready 💞
</button>
</div>
);
}
if (view.status === 'summary' && view.summary) {
const { earned, canReplay, keyName } = view.summary;
const last = view.round.index === view.round.total - 1;
return (
<div className="space-y-5 text-center" aria-live="polite">
{header}
<Marks view={view} />
<motion.div initial={{ scale: 0.6, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="text-6xl" aria-hidden>
{earned ? '🔑' : '🙃'}
</motion.div>
<p className="font-display text-2xl font-bold">
{view.points} out of {view.questionsPerRound}
</p>
<p className="text-stone-300">
{earned
? `In sync! You earned the ${keyName}.`
: canReplay
? `Not quite ${view.pointsForKey}. You get one replay with fresh questions.`
: 'No key for this round. Something to talk about over dessert.'}
</p>
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
{!earned && canReplay ? 'Replay this round ↻' : last ? 'See how we did →' : 'Next round →'}
</button>
</div>
);
}
const q = view.question;
if (!q) return null;
const predict = view.round.type === 'predict';
const subjectName = view.round.subject ? roles[view.round.subject].name : '';
return (
<div className="space-y-5">
{header}
<Marks view={view} />
<div className="card text-center">
<div className="text-xs font-semibold uppercase tracking-widest text-stone-400">
Question {view.questionNumber} of {view.questionsPerRound}
</div>
<p className="mt-2 font-display text-2xl font-bold leading-snug">{q.text}</p>
{view.status === 'question' && (
<p className="mt-2 text-sm text-accent">
{!predict
? 'Pick what you think your partner will pick.'
: view.iAmSubject
? "This one's about you. Answer honestly!"
: `Guess what ${subjectName} will say.`}
</p>
)}
</div>
{view.status === 'question' && (
<>
<div className="grid gap-2" role="radiogroup" aria-label={q.text}>
{q.options.map((option, i) => (
<button
key={i}
role="radio"
aria-checked={view.myAnswer === i}
disabled={view.myAnswer !== null}
onClick={() => send('sync:answer', { questionId: q.id, optionId: i })}
className={`min-h-[52px] rounded-xl border px-4 py-3 text-left text-lg font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
view.myAnswer === i
? 'border-accent bg-accent/25'
: 'border-white/15 bg-white/5 hover:bg-white/10 disabled:opacity-40'
}`}
>
{option}
</button>
))}
</div>
<p className="text-center text-sm text-stone-400" aria-live="polite">
{view.myAnswer !== null
? couch
? `Locked in. Pass the device to ${partnerName}.`
: `Locked in. Waiting for ${partnerName}…`
: view.partnerAnswered
? `${partnerName} has answered. Your turn!`
: 'Your answer stays hidden until you have both answered.'}
</p>
</>
)}
{view.status === 'reveal' && view.reveal && (
<div className="space-y-4" aria-live="polite">
<div className="flex gap-3">
<FlipCard role="A" name={roles.A.name} answer={q.options[view.reveal.A]} delay={0} />
<FlipCard role="B" name={roles.B.name} answer={q.options[view.reveal.B]} delay={0.15} />
</div>
<motion.p
className="text-center font-display text-2xl font-bold"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8 }}
>
{view.reveal.match ? '💞 In sync!' : '🙈 Not this time'}
</motion.p>
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
{view.questionNumber < view.questionsPerRound ? 'Next question →' : 'See the round result →'}
</button>
</div>
)}
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* Defaults; a room overrides these from its game's YAML theme. */
--primary: 123 30 58;
--accent: 232 176 74;
--bg: 18 12 14;
--heading-font: 'Playfair Display';
color-scheme: dark;
}
html,
body,
#root {
min-height: 100%;
}
body {
@apply bg-bg font-sans text-stone-100 antialiased;
overflow-x: hidden;
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgb(var(--primary) / 0.35), transparent);
background-attachment: fixed;
transition: background-color 0.4s;
}
@layer components {
.btn {
@apply inline-flex min-h-[44px] items-center justify-center gap-2 rounded-xl border border-white/15 bg-white/5 px-4 py-2 font-semibold text-stone-100 transition hover:bg-white/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40;
}
.btn-primary {
@apply inline-flex min-h-[44px] items-center justify-center gap-2 rounded-xl bg-accent px-4 py-2 font-semibold text-stone-950 shadow-lg shadow-black/30 transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white disabled:cursor-not-allowed disabled:opacity-40;
}
.card {
@apply rounded-2xl border border-white/10 bg-white/[0.04] p-4;
}
.chip {
@apply inline-flex items-center gap-1 rounded-full border border-white/15 bg-black/30 px-2.5 py-0.5 text-xs font-medium uppercase tracking-wide;
}
.field {
@apply min-h-[44px] w-full rounded-xl border border-white/20 bg-black/40 px-3 py-2 text-base text-stone-100 placeholder:text-stone-500 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/40;
}
}
/* ---------- Markdown ---------- */
.md {
--line: rgb(255 255 255 / 0.18);
overflow-wrap: anywhere;
}
.md.paper {
--line: rgb(0 0 0 / 0.22);
}
.md > * + * {
margin-top: 0.7em;
}
.md h1,
.md h2,
.md h3 {
font-family: var(--heading-font), 'Playfair Display', serif;
font-weight: 700;
line-height: 1.2;
}
.md h1 {
font-size: 1.5em;
}
.md h2 {
font-size: 1.3em;
letter-spacing: 0.04em;
}
.md ul {
list-style: disc;
padding-left: 1.2em;
}
.md ol {
list-style: decimal;
padding-left: 1.4em;
}
.md li + li {
margin-top: 0.25em;
}
.md blockquote {
border-left: 3px solid rgb(var(--accent) / 0.7);
padding-left: 0.9em;
opacity: 0.92;
}
.md hr {
border-color: var(--line);
}
.md table {
border-collapse: collapse;
font-size: 0.9em;
white-space: nowrap;
}
.md th,
.md td {
border: 1px solid var(--line);
padding: 0.3em 0.6em;
text-align: left;
}
.md th:empty {
display: none;
}
.md pre {
overflow-x: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 0.9em;
line-height: 1.5;
white-space: pre;
}
.md pre code {
font-size: inherit;
}
.md code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.92em;
}
.md .scroll-x {
overflow-x: auto;
max-width: 100%;
}
/* ---------- Signal lamp ---------- */
.lamp {
transition: background-color 40ms, box-shadow 40ms;
background-color: #2a0e0e;
box-shadow: inset 0 0 12px rgb(0 0 0 / 0.8);
}
.lamp.on {
background-color: #ff4d3d;
box-shadow: 0 0 28px 10px rgb(255 77 61 / 0.65), inset 0 0 10px rgb(255 220 200 / 0.8);
}
@keyframes shake {
20%,
60% {
transform: translateX(-6px);
}
40%,
80% {
transform: translateX(6px);
}
}
.shake {
animation: shake 0.35s;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+89
View File
@@ -0,0 +1,89 @@
import { useEffect, useState } from 'react';
import type { GameSummary, RoomState } from '@two-keys/shared';
import { getToken, saveSession } from './session';
import { request, socket } from './socket';
/** Re-renders every `ms` and returns the current time. */
export function useNow(ms = 1000): number {
const [now, setNow] = useState(Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), ms);
return () => clearInterval(id);
}, [ms]);
return now;
}
let gamesCache: Promise<GameSummary[]> | null = null;
export function useGames(): GameSummary[] | null {
const [games, setGames] = useState<GameSummary[] | null>(null);
useEffect(() => {
gamesCache ??= fetch('/api/games').then((r) => {
if (!r.ok) throw new Error('failed to load games');
return r.json();
});
let alive = true;
gamesCache.then((g) => alive && setGames(g)).catch(() => {
gamesCache = null;
if (alive) setGames([]);
});
return () => {
alive = false;
};
}, []);
return games;
}
export interface RoomSnapshot {
state: RoomState;
/** Local time the state arrived, for running clocks between updates. */
receivedAt: number;
}
// One join per room at a time, shared between effect runs (StrictMode mounts twice).
const joins = new Map<string, ReturnType<typeof request>>();
function join(code: string) {
let pending = joins.get(code);
if (!pending) {
pending = request('room:join', { code, playerToken: getToken(code) }).then((res) => {
joins.delete(code);
if (res.ok) saveSession(res.code, res.playerToken);
return res;
});
joins.set(code, pending);
}
return pending;
}
/** Joins (or rejoins) a room and keeps its sanitized state up to date. */
export function useRoom(code: string) {
const [snapshot, setSnapshot] = useState<RoomSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(socket.connected);
useEffect(() => {
let alive = true;
const onConnect = () => {
setConnected(true);
join(code).then((res) => alive && setError(res.ok ? null : res.message));
};
const onDisconnect = () => setConnected(false);
const onState = (state: RoomState) => {
if (state.code === code) setSnapshot({ state, receivedAt: Date.now() });
};
socket.on('connect', onConnect);
socket.on('disconnect', onDisconnect);
socket.on('room:state', onState);
if (socket.connected) onConnect();
return () => {
alive = false;
socket.off('connect', onConnect);
socket.off('disconnect', onDisconnect);
socket.off('room:state', onState);
};
}, [code]);
return { snapshot, error, connected };
}
+58
View File
@@ -0,0 +1,58 @@
// localStorage can throw (private mode, blocked storage), so every access is wrapped.
function read(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function write(key: string, value: string | null) {
try {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch {
/* play on without persistence */
}
}
const tokenKey = (code: string) => `twokeys:room:${code}`;
const LAST_ROOM = 'twokeys:last-room';
/** The seat token for a room, so a refresh rejoins the same seat. */
export const getToken = (code: string) => read(tokenKey(code));
export function saveSession(code: string, playerToken: string) {
write(tokenKey(code), playerToken);
write(LAST_ROOM, code);
}
export const getLastRoom = () => read(LAST_ROOM);
export const forgetLastRoom = () => write(LAST_ROOM, null);
export interface BestResult {
finalSeconds: number;
rank: string;
hintsUsed: number;
keysEarned: number;
}
export function getBest(gameId: string): BestResult | null {
try {
return JSON.parse(read(`twokeys:best:${gameId}`) ?? 'null');
} catch {
return null;
}
}
/** Keeps the best result per game: more keys first, then the faster time. */
export function saveBest(gameId: string, result: BestResult): boolean {
const best = getBest(gameId);
const better =
!best ||
result.keysEarned > best.keysEarned ||
(result.keysEarned === best.keysEarned && result.finalSeconds < best.finalSeconds);
if (better) write(`twokeys:best:${gameId}`, JSON.stringify(result));
return better;
}
+19
View File
@@ -0,0 +1,19 @@
import { io } from 'socket.io-client';
import type { JoinAck } from '@two-keys/shared';
import { pushToast } from './toasts';
export const socket = io({ transports: ['websocket', 'polling'] });
socket.on('toast', ({ message }: { message: string }) => pushToast(message));
socket.on('error', ({ message }: { message: string }) => pushToast(message, 'error'));
/** Emit an event that the server acknowledges (room:create, room:join). */
export function request(event: string, payload: object): Promise<JoinAck> {
return new Promise((resolve) => {
socket.timeout(8000).emit(event, payload, (err: Error | null, res: JoinAck) => {
resolve(err ? { ok: false, code: 'timeout', message: "Can't reach the server. Check your connection." } : res);
});
});
}
export type Send = (event: string, payload?: Record<string, unknown>) => void;
+45
View File
@@ -0,0 +1,45 @@
import { useEffect } from 'react';
import type { CSSProperties } from 'react';
import type { Category, Role, Theme } from '@two-keys/shared';
/** "#7b1e3a" → "123 30 58", the form Tailwind's rgb(var(--x) / alpha) wants. */
export function hexToRgb(hex: string): string {
const n = parseInt(hex.slice(1), 16);
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`;
}
export function themeVars(theme: Theme): CSSProperties {
return {
'--primary': hexToRgb(theme.primary),
'--accent': hexToRgb(theme.accent),
'--bg': hexToRgb(theme.background),
'--heading-font': `'${theme.headingFont}'`,
} as CSSProperties;
}
/** Applies a game's theme to the whole page while a room is open. */
export function useTheme(theme: Theme | undefined) {
const key = theme ? JSON.stringify(theme) : '';
useEffect(() => {
if (!key) return;
const root = document.documentElement;
const vars = themeVars(JSON.parse(key)) as Record<string, string>;
for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value);
return () => {
for (const name of Object.keys(vars)) root.style.removeProperty(name);
};
}, [key]);
}
export const CATEGORY_LABEL: Record<Category, string> = {
'murder-mystery': 'Murder Mystery',
'escape-room': 'Escape Room',
'co-op': 'Co-op',
};
/** A is warm (amber), B is cool (teal). */
export const ROLE_TEXT: Record<Role, string> = { A: 'text-roleA', B: 'text-roleB' };
export const ROLE_BORDER: Record<Role, string> = { A: 'border-roleA/60', B: 'border-roleB/60' };
export const ROLE_BG: Record<Role, string> = { A: 'bg-roleA/15', B: 'bg-roleB/15' };
export const difficultyKeys = (n: number) => '🔑'.repeat(n);
+32
View File
@@ -0,0 +1,32 @@
import { useSyncExternalStore } from 'react';
export interface Toast {
id: number;
message: string;
kind: 'info' | 'error';
}
let toasts: Toast[] = [];
let nextId = 1;
const listeners = new Set<() => void>();
function set(next: Toast[]) {
toasts = next;
listeners.forEach((l) => l());
}
export function pushToast(message: string, kind: Toast['kind'] = 'info') {
const id = nextId++;
set([...toasts.slice(-2), { id, message, kind }]);
setTimeout(() => set(toasts.filter((t) => t.id !== id)), 4500);
}
export function useToasts(): Toast[] {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
() => toasts,
);
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+87
View File
@@ -0,0 +1,87 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { GameCard } from '../components/GameCard';
import { SiteShell } from '../components/SiteShell';
import { useGames } from '../lib/hooks';
import { getLastRoom } from '../lib/session';
function JoinForm() {
const [code, setCode] = useState('');
const navigate = useNavigate();
const submit = (e: FormEvent) => {
e.preventDefault();
const clean = code.trim().toUpperCase();
if (clean) navigate(`/join/${clean}`);
};
return (
<form onSubmit={submit} className="flex w-full max-w-xs gap-2">
<input
className="field text-center font-mono uppercase tracking-[0.3em]"
value={code}
onChange={(e) => setCode(e.target.value.replace(/[^a-zA-Z0-9]/g, '').slice(0, 5))}
placeholder="CODE"
aria-label="Room code from your partner"
autoCapitalize="characters"
autoComplete="off"
spellCheck={false}
/>
<button className="btn shrink-0" disabled={code.length < 5}>
Join
</button>
</form>
);
}
export function Home() {
const games = useGames();
const lastRoom = getLastRoom();
return (
<SiteShell>
<section className="flex flex-col items-center py-10 text-center sm:py-16">
<p className="chip text-accent">Date night, solved</p>
<h1 className="mt-5 max-w-3xl font-display text-4xl font-bold leading-[1.1] sm:text-6xl">
Two players. One mystery. <span className="text-accent">Every key needs both of you.</span>
</h1>
<p className="mt-5 max-w-xl text-lg leading-8 text-stone-300">
Co-op mysteries for couples. You each see different clues, so neither of you can crack it alone. Talk it
out, pool what you know, and open the locks together.
</p>
<div className="mt-8 flex w-full flex-col items-center gap-3">
<Link to="/games" className="btn-primary px-8 py-3 text-lg">
Pick a mystery
</Link>
<span className="text-sm text-stone-500">or join your partner's game</span>
<JoinForm />
{lastRoom && (
<Link to={`/room/${lastRoom}`} className="py-3 text-sm text-accent underline underline-offset-4">
Back to your last game ({lastRoom})
</Link>
)}
</div>
</section>
<section aria-labelledby="library">
<div className="mb-4 flex items-end justify-between">
<h2 id="library" className="font-display text-2xl font-bold">
The library
</h2>
<Link to="/how-to-play" className="-my-3 py-3 text-sm text-accent underline underline-offset-4">
How does it work?
</Link>
</div>
{games === null ? (
<p className="text-stone-400">Loading games…</p>
) : games.length === 0 ? (
<p className="text-red-300">Couldn't load the games. Is the server running?</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{games.map((game) => (
<GameCard key={game.id} game={game} />
))}
</div>
)}
</section>
</SiteShell>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { Link } from 'react-router-dom';
import { SiteShell } from '../components/SiteShell';
const STEPS = [
{
icon: '🎟️',
title: 'Pick a game, share the code',
body: 'One of you starts a game and gets a five-letter room code and a link. The other joins on their own phone. Only have one device? Choose couch mode and pass it back and forth.',
},
{
icon: '🎭',
title: 'Choose your roles',
body: "Every game has two roles, like Inspector and Estate Insider. Your role decides which clues you get. Your partner gets a completely different set.",
},
{
icon: '🗣️',
title: 'Talk it out',
body: "Describe what you see. Don't just show your screen, that's cheating (and less fun). Every lock needs something from each of you.",
},
{
icon: '🔑',
title: 'Open the locks together',
body: 'Either of you can type the answer. Each lock you open earns a key. Collect them all to reach the finale.',
},
];
const TIPS = [
['💡 Hints', 'Three per lock, from a nudge to nearly the answer. Each costs two minutes on your final time, and you both see it.'],
['📝 Shared notepad', 'Jot down times, names and theories. You both see it update live.'],
['👋 Pings', "Tap your partner's name to send a little wave, a magnifying glass, a lightbulb or a heart."],
['🔄 Lost connection?', 'Just refresh or reopen the link. You land back in the same seat with all your progress.'],
['🏆 Ranks', 'Finish under the estimated time with no hints to become Soulmate Sleuths.'],
] as const;
export function HowToPlay() {
return (
<SiteShell>
<h1 className="mt-4 font-display text-4xl font-bold">How to play</h1>
<p className="mt-2 max-w-2xl text-lg text-stone-300">
Best played on a call or side by side. There's no in-game chat on purpose: the talking is the game.
</p>
<ol className="mt-8 grid gap-4 sm:grid-cols-2">
{STEPS.map((step, i) => (
<li key={step.title} className="card">
<div className="flex items-center gap-3">
<span className="text-3xl" aria-hidden>{step.icon}</span>
<h2 className="font-display text-xl font-bold">
{i + 1}. {step.title}
</h2>
</div>
<p className="mt-2 leading-7 text-stone-300">{step.body}</p>
</li>
))}
</ol>
<h2 className="mt-10 font-display text-2xl font-bold">Good to know</h2>
<dl className="mt-4 space-y-3">
{TIPS.map(([term, text]) => (
<div key={term} className="card !py-3">
<dt className="font-semibold">{term}</dt>
<dd className="mt-0.5 text-stone-300">{text}</dd>
</div>
))}
</dl>
<div className="mt-10 text-center">
<Link to="/games" className="btn-primary px-8 py-3 text-lg">
Pick a mystery
</Link>
</div>
</SiteShell>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { useState } from 'react';
import type { Category } from '@two-keys/shared';
import { GameCard } from '../components/GameCard';
import { SiteShell } from '../components/SiteShell';
import { useGames } from '../lib/hooks';
import { CATEGORY_LABEL } from '../lib/theme';
const FILTERS: (Category | 'all')[] = ['all', 'murder-mystery', 'escape-room', 'co-op'];
export function Library() {
const games = useGames();
const [filter, setFilter] = useState<Category | 'all'>('all');
const shown = (games ?? []).filter((g) => filter === 'all' || g.category === filter);
return (
<SiteShell>
<h1 className="mt-4 font-display text-4xl font-bold">Game library</h1>
<p className="mt-2 text-stone-300">
New here? Start with <strong>The Clockmaker's Attic</strong>. It's the gentlest first game.
</p>
<div className="mt-5 flex flex-wrap gap-2" role="group" aria-label="Filter by category">
{FILTERS.map((f) => (
<button
key={f}
aria-pressed={filter === f}
onClick={() => setFilter(f)}
className={`min-h-[40px] rounded-full border px-4 py-1.5 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
filter === f ? 'border-accent bg-accent text-stone-950' : 'border-white/15 bg-white/5 hover:bg-white/10'
}`}
>
{f === 'all' ? 'All games' : CATEGORY_LABEL[f]}
</button>
))}
</div>
{games === null ? (
<p className="mt-6 text-stone-400">Loading games…</p>
) : games.length === 0 ? (
<p className="mt-6 text-red-300">Couldn't load the games. Is the server running?</p>
) : (
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{shown.map((game) => (
<GameCard key={game.id} game={game} />
))}
</div>
)}
</SiteShell>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { useState } from 'react';
import type { Role, RoomState } from '@two-keys/shared';
import { Markdown, RoleBadge } from '../components/ui';
import { socket } from '../lib/socket';
import { CATEGORY_LABEL, ROLE_BORDER, difficultyKeys } from '../lib/theme';
function ShareBox({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const link = `${window.location.origin}/join/${code}`;
const copy = async () => {
try {
await navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* clipboard blocked: the link is on screen to copy by hand */
}
};
return (
<section className="card text-center">
<div className="text-xs font-bold uppercase tracking-widest text-stone-400">Room code</div>
<div className="mt-1 font-mono text-5xl font-bold tracking-[0.25em] text-accent" aria-label={`Room code ${code.split('').join(' ')}`}>
{code}
</div>
<p className="mt-3 break-all font-mono text-xs text-stone-400">{link}</p>
<button className="btn mt-3 w-full" onClick={copy}>
{copied ? 'Copied ✓' : 'Copy invite link'}
</button>
</section>
);
}
export function Lobby({ state }: { state: RoomState }) {
const { game, you, partner } = state;
const couch = state.mode === 'couch';
const mySeat = you.seat === 'A' || you.seat === 'B' ? you.seat : null;
const holder = (role: Role) => (couch ? null : mySeat === role ? 'You' : partner?.seat === role ? 'Your partner' : null);
let status: string;
if (couch) status = 'One device, two detectives. Decide who is who, then start.';
else if (!partner) status = 'Waiting for your partner to join…';
else if (!mySeat) status = 'Pick a role. Your partner gets the other one.';
else if (you.ready && !partner.ready) status = "You're ready. Waiting for your partner…";
else if (!you.ready && partner.ready) status = 'Your partner is ready. Tap Ready when you are!';
else status = "Both here. Tap Ready when you're set.";
return (
<div className="mx-auto w-full max-w-2xl space-y-5 px-4 py-6">
<header className="text-center">
<div className="flex flex-wrap items-center justify-center gap-2 text-xs text-stone-300">
<span className="chip text-accent">{CATEGORY_LABEL[game.category]}</span>
<span>⏱ {game.estimatedMinutes} min</span>
<span aria-label={`Difficulty ${game.difficulty} of 3`}>{difficultyKeys(game.difficulty)}</span>
</div>
<h1 className="mt-3 font-display text-4xl font-bold leading-tight">{game.title}</h1>
<p className="mt-2 text-stone-300">{game.tagline}</p>
</header>
{!couch && <ShareBox code={state.code} />}
<section aria-labelledby="roles">
<h2 id="roles" className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">
{couch ? 'The two roles' : 'Choose your role'}
</h2>
<div className="grid gap-3 sm:grid-cols-2">
{(['A', 'B'] as const).map((role) => {
const who = holder(role);
const mine = mySeat === role;
return (
<div key={role} className={`card flex flex-col ${mine ? `${ROLE_BORDER[role]} bg-white/[0.08]` : ''}`}>
<div className="flex items-center justify-between gap-2">
<RoleBadge role={role} name={game.roles[role].name} />
{who && <span className="text-xs font-semibold text-stone-300">{who}</span>}
</div>
<p className="mt-2 flex-1 text-sm leading-6 text-stone-300">{game.roles[role].blurb}</p>
{!couch && (
<button
className="btn mt-3"
disabled={mine}
onClick={() => socket.emit('room:pickRole', { role })}
>
{mine ? 'This is you' : who ? 'Swap roles' : `Play as ${game.roles[role].name}`}
</button>
)}
</div>
);
})}
</div>
</section>
<section className="card">
<Markdown className="leading-7 text-stone-200">{game.intro}</Markdown>
</section>
<div className="sticky bottom-0 -mx-4 border-t border-white/10 bg-bg/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur">
<p className="mb-2 text-center text-sm text-stone-300" aria-live="polite">
{!couch && (
<span
aria-hidden
className={`mr-2 inline-block h-2 w-2 rounded-full ${partner?.online ? 'bg-emerald-400' : 'bg-stone-500'}`}
/>
)}
{status}
</p>
<button
className={you.ready ? 'btn w-full' : 'btn-primary w-full'}
disabled={!couch && !mySeat}
onClick={() => socket.emit('room:ready')}
>
{couch ? 'Start the game' : you.ready ? 'Ready ✓ (tap to undo)' : "I'm ready"}
</button>
<p className="mt-2 text-center text-xs text-stone-500">Best played on a call or side by side.</p>
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { useCallback, useState, type CSSProperties } from 'react';
import { Link } from 'react-router-dom';
import { otherRole, type Role, type RoomState } from '@two-keys/shared';
import { KeyRing } from '../components/KeyRing';
import { Notepad } from '../components/Notepad';
import { PartnerStatus, PingOverlay } from '../components/PartnerStatus';
import { PassDeviceScreen } from '../components/PassDeviceScreen';
import { Timer } from '../components/Timer';
import { RoleBadge } from '../components/ui';
import { DefuseGame } from '../engines/DefuseGame';
import { LabyrinthGame } from '../engines/LabyrinthGame';
import { StoryGame } from '../engines/StoryGame';
import { SyncGame } from '../engines/SyncGame';
import { socket, type Send } from '../lib/socket';
const ROLE_COLOR: Record<Role, string> = { A: '#f5b942', B: '#2dd4bf' };
export function Play({ state, receivedAt }: { state: RoomState; receivedAt: number }) {
const couch = state.you.seat === 'both';
const [activeRole, setActiveRole] = useState<Role>(couch ? 'A' : (state.you.seat as Role));
// Couch mode: the role waiting behind the "pass the device" cover, if it's up.
const [cover, setCover] = useState<Role | null>(couch ? 'A' : null);
const send = useCallback<Send>(
(event, payload = {}) => socket.emit(event, couch ? { ...payload, asRole: activeRole } : payload),
[couch, activeRole],
);
const { game } = state;
const view = state.views[activeRole];
if (cover) {
return (
<PassDeviceScreen
role={cover}
roleName={game.roles[cover].name}
onReveal={() => {
setActiveRole(cover);
setCover(null);
}}
/>
);
}
return (
<div className="mx-auto w-full max-w-2xl px-4" style={{ '--role': ROLE_COLOR[activeRole] } as CSSProperties}>
<header
className={`${view?.engine === 'defuse' ? 'relative' : 'sticky top-0'} z-20 -mx-4 border-b border-white/10 bg-bg/95 px-4 py-2 backdrop-blur`}
>
<div className="flex items-center justify-between gap-3">
<Link to="/" className="-my-2 shrink-0 py-2 text-lg focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" aria-label="Two Keys home">
🔑🔑
</Link>
<h1 className="min-w-0 flex-1 truncate font-display text-base font-bold">{game.title}</h1>
<Timer timer={state.timer} receivedAt={receivedAt} />
</div>
<div className="mt-2 flex items-center gap-2">
<KeyRing keys={state.keys} total={game.totalKeys} />
{couch ? (
<button
className="flex min-h-[36px] min-w-0 items-center gap-1.5 rounded-full border border-white/15 bg-black/30 px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
onClick={() => setCover(otherRole(activeRole))}
>
<span className="truncate">Pass to {game.roles[otherRole(activeRole)].name}</span>
<span aria-hidden>👀</span>
</button>
) : (
<PartnerStatus state={state} />
)}
<div className="flex-1" />
<Notepad text={state.notepad} />
</div>
</header>
<main className="pb-5 pt-3">
<div className="mb-4 flex items-center gap-2 text-xs text-stone-400">
<span>You are</span>
<RoleBadge role={activeRole} name={game.roles[activeRole].name} />
</div>
{view?.engine === 'story' && <StoryGame view={view} state={state} role={activeRole} send={send} />}
{view?.engine === 'defuse' && <DefuseGame view={view} state={state} receivedAt={receivedAt} send={send} />}
{view?.engine === 'sync' && <SyncGame view={view} state={state} role={activeRole} send={send} />}
{view?.engine === 'labyrinth' && <LabyrinthGame view={view} state={state} send={send} />}
</main>
{!couch && <PingOverlay roles={game.roles} />}
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { Link } from 'react-router-dom';
import { formatClock, type GameResult, type RoomState } from '@two-keys/shared';
import { GameCard } from '../components/GameCard';
import { Markdown } from '../components/ui';
import { useGames } from '../lib/hooks';
import { forgetLastRoom, saveBest } from '../lib/session';
const RANK_ICON: Record<string, string> = {
'Soulmate Sleuths': '💞',
'Dream Team': '🌟',
'Getting There': '🧩',
};
function Stat({ label, value, note }: { label: string; value: string; note?: string }) {
return (
<div className="card !p-3 text-center">
<div className="font-mono text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xs uppercase tracking-wider text-stone-400">{label}</div>
{note && <div className="mt-0.5 text-[11px] text-red-300">{note}</div>}
</div>
);
}
export function Results({ state, result }: { state: RoomState; result: GameResult }) {
const { game } = state;
const games = useGames();
const [isBest, setIsBest] = useState(false);
// Remember the best result for this game on this device.
useEffect(() => {
forgetLastRoom();
if (!result.success) return;
setIsBest(
saveBest(game.id, {
finalSeconds: result.finalSeconds,
rank: result.rank,
hintsUsed: result.hintsUsed,
keysEarned: result.keysEarned,
}),
);
}, [game.id, result]);
const others = (games ?? []).filter((g) => g.id !== game.id);
return (
<div className="mx-auto w-full max-w-2xl space-y-6 px-4 py-8">
<header className="text-center">
<div className="text-xs font-bold uppercase tracking-widest text-accent">{game.title}</div>
<motion.div
className="mt-3 text-7xl"
aria-hidden
initial={{ scale: 0.3, rotate: -20, opacity: 0 }}
animate={{ scale: 1, rotate: 0, opacity: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 12 }}
>
{result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
</motion.div>
<h1 className="mt-3 font-display text-4xl font-bold">{result.rank}</h1>
{isBest && <p className="mt-1 text-sm font-semibold text-accent">🏆 New personal best</p>}
</header>
{result.ending && (
<section className="card">
<Markdown className="text-lg leading-8">{result.ending}</Markdown>
</section>
)}
{result.reveal && (
<section className="rounded-2xl border border-accent/40 bg-accent/10 p-4">
<h2 className="text-xs font-bold uppercase tracking-widest text-accent">What really happened</h2>
<Markdown className="mt-2 leading-7">{result.reveal}</Markdown>
</section>
)}
<section className="grid grid-cols-2 gap-3" aria-label="Your results">
<Stat
label="Final time"
value={formatClock(result.finalSeconds)}
note={result.penaltySeconds > 0 ? `includes +${formatClock(result.penaltySeconds)} penalty` : undefined}
/>
<Stat label="Keys earned" value={`${result.keysEarned}/${result.totalKeys}`} />
<Stat label="Hints used" value={String(result.hintsUsed)} />
<Stat label="Wrong guesses" value={String(result.wrongGuesses)} />
</section>
<p className="text-center text-xs text-stone-500">
Estimated time for this game: {game.estimatedMinutes} minutes.
</p>
{others.length > 0 && (
<section aria-labelledby="another">
<h2 id="another" className="mb-3 font-display text-2xl font-bold">
Play another
</h2>
<div className="-mx-4 flex snap-x snap-mandatory gap-3 overflow-x-auto px-4 pb-3">
{others.map((g) => (
<GameCard key={g.id} game={g} className="w-[78%] max-w-xs shrink-0 snap-center" />
))}
</div>
</section>
)}
<div className="text-center">
<Link to="/" className="btn">
Back to the library
</Link>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Link, Navigate, useParams } from 'react-router-dom';
import { Spinner } from '../components/ui';
import { useRoom } from '../lib/hooks';
import { useTheme } from '../lib/theme';
import { Lobby } from './Lobby';
import { Play } from './Play';
import { Results } from './Results';
/** /join/K7MRX is the share link; the room page does the actual joining. */
export function JoinRedirect() {
const { code = '' } = useParams();
return <Navigate to={`/room/${code.toUpperCase()}`} replace />;
}
export function Room() {
const code = (useParams().code ?? '').toUpperCase();
const { snapshot, error, connected } = useRoom(code);
useTheme(snapshot?.state.game.theme);
if (error && !snapshot) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 px-6 text-center">
<div className="text-5xl" aria-hidden>🗝️</div>
<h1 className="font-display text-3xl font-bold">This door won't open</h1>
<p className="max-w-sm text-stone-300">{error}</p>
<Link to="/" className="btn-primary">
Back to the library
</Link>
</div>
);
}
if (!snapshot) return <Spinner label={`Opening room ${code}…`} />;
const { state, receivedAt } = snapshot;
return (
<div className="min-h-dvh">
{!connected && (
<div role="status" className="sticky top-0 z-30 bg-red-900 px-4 py-1.5 text-center text-sm font-medium">
Connection lost. Reconnecting…
</div>
)}
{state.phase === 'lobby' && <Lobby state={state} />}
{/* Keyed by seat so a couch/duo rejoin starts from the right role. */}
{state.phase === 'playing' && <Play key={state.you.seat} state={state} receivedAt={receivedAt} />}
{state.phase === 'finished' && state.result && <Results state={state} result={state.result} />}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// Per-game theme, set as CSS variables from the YAML `theme` block.
primary: 'rgb(var(--primary) / <alpha-value>)',
accent: 'rgb(var(--accent) / <alpha-value>)',
bg: 'rgb(var(--bg) / <alpha-value>)',
// Role colours: A is warm, B is cool.
roleA: '#f5b942',
roleB: '#2dd4bf',
},
fontFamily: {
display: ['var(--heading-font)', 'Playfair Display', 'Georgia', 'serif'],
serif: ['Playfair Display', 'Georgia', 'serif'],
sans: ['Inter', 'system-ui', 'sans-serif'],
hand: ['Caveat', 'cursive'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
},
},
},
plugins: [],
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
const server = 'http://localhost:3001';
export default defineConfig({
plugins: [react()],
server: {
host: true,
proxy: {
'/api': server,
'/socket.io': { target: server, ws: true },
},
},
});