semantick / static /index.html
orendar's picture
Upload 2 files
0b42d8e verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://unpkg.com" crossorigin />
<link rel="dns-prefetch" href="//unpkg.com" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<title>Semantick</title>
<style>
body { margin: 0; background: #1a1a19; -webkit-tap-highlight-color: transparent; }
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useState, useRef, useEffect, useCallback } = React;
const API_BASE = "";
const LOBBY_SESSION_KEY = "semantick_lobby_session";
function scoreColor(score) {
return score >= 0.90 ? "#22c55e" :
score >= 0.70 ? "#84cc16" :
score >= 0.50 ? "#eab308" :
score >= 0.30 ? "#f97316" : "#ef4444";
}
function ScoreBar({ score }) {
const pct = Math.min(score * 100, 100);
const color = scoreColor(score);
return (
<div style={{
width: "100%", height: 6, background: "rgba(255,255,255,0.06)",
borderRadius: 3, overflow: "hidden"
}}>
<div style={{
width: `${pct}%`, height: "100%", background: color,
borderRadius: 3, transition: "width 0.6s cubic-bezier(0.22, 1, 0.36, 1)"
}} />
</div>
);
}
function GuessRow({ guess, index, isNew, isBest }) {
return (
<div className="guess-grid" style={{
display: "grid",
gridTemplateColumns: "36px 1fr 56px 56px 1fr",
alignItems: "center",
gap: 10,
padding: "10px 16px",
background: isBest ? "rgba(34,197,94,0.08)" : isNew ? "rgba(255,255,255,0.03)" : "transparent",
borderLeft: isBest ? "2px solid #22c55e" : "2px solid transparent",
borderRadius: 4,
animation: isNew ? "slideIn 0.3s ease" : "none",
fontVariantNumeric: "tabular-nums",
}}>
<span style={{ color: "#666", fontSize: 13, fontFamily: "'DM Mono', monospace" }}>
{guess.isHint ? "\u2605" : `#${guess.guessNum}`}
</span>
<span style={{
color: guess.isHint ? "#eab308" : "#e8e6e1", fontSize: 15,
fontFamily: "'DM Mono', monospace",
textTransform: "lowercase", letterSpacing: "0.02em",
fontStyle: guess.isHint ? "italic" : "normal",
}}>
{guess.word}
</span>
<span style={{
color: scoreColor(guess.score),
fontSize: 15, fontWeight: 600,
fontFamily: "'DM Mono', monospace", textAlign: "right"
}}>
{guess.score.toFixed(2)}
</span>
<span className="rank-col" style={{
color: "#555", fontSize: 12,
fontFamily: "'DM Mono', monospace", textAlign: "right"
}}>
{guess.rank ? `#${guess.rank}` : ""}
</span>
<ScoreBar score={guess.score} />
</div>
);
}
function HowToPlay({ show, onToggle }) {
if (!show) return (
<button
onClick={onToggle}
onMouseDown={e => e.preventDefault()}
style={{
background: "transparent", border: "none", color: "#666",
fontSize: 12, fontFamily: "'DM Mono', monospace", cursor: "pointer",
letterSpacing: "0.05em", padding: "4px 0", marginTop: -8, marginBottom: 8,
}}
>
how to play?
</button>
);
return (
<div style={{
background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 8, padding: "16px 20px", marginBottom: 16,
fontSize: 13, lineHeight: 1.6, color: "#aaa",
fontFamily: "'DM Sans', sans-serif",
}}>
<div style={{ marginBottom: 8 }}>
A secret word has been chosen. Type words to guess it - after each guess you'll see
how <strong style={{ color: "#e8e6e1" }}>semantically similar</strong> your word is to the secret word.
</div>
<div style={{ marginBottom: 8 }}>
<strong style={{ color: "#e8e6e1" }}>Score</strong> (0-1): how close your guess is compared to all words in the dictionary.
Higher is better. <span style={{ color: "#22c55e" }}>Green</span> means very close,{" "}
<span style={{ color: "#ef4444" }}>red</span> means far away.
</div>
<div style={{ marginBottom: 8 }}>
<strong style={{ color: "#e8e6e1" }}>Rank</strong>: your word's position among all ~{"\u2009"}80k words,
sorted by similarity. Rank #1 = the secret word itself.
</div>
<div style={{ marginBottom: 12 }}>
Use <strong style={{ color: "#eab308" }}>hints</strong> to reveal the part of speech, category clues, or similar words at progressively closer ranks (#1000, #100, #10, #9, ... #2).
</div>
<button
onClick={onToggle}
onMouseDown={e => e.preventDefault()}
style={{
background: "transparent", border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 6, padding: "6px 14px", color: "#888",
fontSize: 12, fontFamily: "'DM Mono', monospace", cursor: "pointer",
}}
>
got it
</button>
</div>
);
}
// Player color palette for multiplayer
const PLAYER_COLORS = ["#22c55e", "#3b82f6", "#f97316", "#a855f7", "#ec4899", "#14b8a6", "#eab308", "#ef4444"];
function playerColor(name, players) {
const idx = players.indexOf(name);
return PLAYER_COLORS[idx >= 0 ? idx % PLAYER_COLORS.length : 0];
}
function buildHintTypeLabels({
posRevealed,
categoryHints,
definitionText,
definitionDone,
concretenessLabel,
vadLabel,
sensorimotorLabel,
glasgowLabel,
conceptnetHints,
hintsUsed,
}) {
const labels = [];
if (posRevealed) labels.push("Part of Speech");
if ((categoryHints || []).length > 0) labels.push(`Category x${categoryHints.length}`);
if (definitionText || definitionDone) labels.push("Definition");
if (concretenessLabel) labels.push("Concreteness");
if (vadLabel) labels.push("Emotional Tone");
if (sensorimotorLabel) labels.push("Senses & Action");
if (glasgowLabel) labels.push("Familiarity & Imagery");
if ((conceptnetHints || []).length > 0) labels.push(`Semantic Clues x${conceptnetHints.length}`);
if (hintsUsed > 0) labels.push(`Similar Word x${hintsUsed}`);
return labels;
}
function LobbyScreen({ onBack, initialSession, onSessionSave, onSessionClear }) {
const [mode, setMode] = useState(null); // null | "create" | "join"
const [nameInput, setNameInput] = useState("");
const [codeInput, setCodeInput] = useState("");
const [error, setError] = useState("");
const [lobbyCode, setLobbyCode] = useState(null);
const [players, setPlayers] = useState([]);
const [myName, setMyName] = useState(initialSession?.name || "");
const [playerId, setPlayerId] = useState(initialSession?.playerId || null);
const [isHost, setIsHost] = useState(false);
const [host, setHost] = useState("");
const [difficulty, setDifficulty] = useState("medium");
const [lobbyStartHints, setLobbyStartHints] = useState(0);
const [ws, setWs] = useState(null);
const [codeCopied, setCodeCopied] = useState(false);
const [showLeaveLobbyConfirm, setShowLeaveLobbyConfirm] = useState(false);
// Game state (multiplayer)
const [inGame, setInGame] = useState(false);
const [vocabSize, setVocabSize] = useState(0);
const [guesses, setGuesses] = useState([]);
const [guessCount, setGuessCount] = useState(0);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [solved, setSolved] = useState(false);
const [gaveUp, setGaveUp] = useState(false);
const [secretWord, setSecretWord] = useState("");
const [winner, setWinner] = useState("");
const [hintsUsed, setHintsUsed] = useState(0);
const [totalHints, setTotalHints] = useState(0);
const [seed, setSeed] = useState("");
const [showHintMenu, setShowHintMenu] = useState(false);
const [posRevealed, setPosRevealed] = useState(null);
const [categoryHints, setCategoryHints] = useState([]);
const [concretenessLabel, setConcretenessLabel] = useState(null);
const [vadLabel, setVadLabel] = useState(null);
const [sensorimotorLabel, setSensorimotorLabel] = useState(null);
const [glasgowLabel, setGlasgowLabel] = useState(null);
const [definitionText, setDefinitionText] = useState(null);
const [definitionDone, setDefinitionDone] = useState(false);
const [conceptnetHints, setConceptnetHints] = useState([]);
const [hasDefinition, setHasDefinition] = useState(false);
const [hasConcreteness, setHasConcreteness] = useState(false);
const [hasVad, setHasVad] = useState(false);
const [hasSensorimotor, setHasSensorimotor] = useState(false);
const [hasGlasgow, setHasGlasgow] = useState(false);
const [hasConceptnet, setHasConceptnet] = useState(false);
const [hasCategories, setHasCategories] = useState(false);
const [lastResult, setLastResult] = useState(null);
const [latestGuessWord, setLatestGuessWord] = useState(null);
const [showGiveUp, setShowGiveUp] = useState(false);
const [gameError, setGameError] = useState("");
const inputRef = useRef(null);
const wsRef = useRef(null);
const sessionRef = useRef(initialSession || null);
const reconnectEnabledRef = useRef(true);
const reconnectTimerRef = useRef(null);
const manualLeaveRef = useRef(false);
const HINT_RANKS = [1000, 100, 10, 9, 8, 7, 6, 5, 4, 3, 2];
const MAX_HINTS = HINT_RANKS.length;
const resetGameState = () => {
setGuesses([]); setGuessCount(0); setSolved(false); setGaveUp(false);
setSecretWord(""); setWinner(""); setHintsUsed(0); setTotalHints(0);
setSeed(""); setShowHintMenu(false); setPosRevealed(null);
setCategoryHints([]); setConcretenessLabel(null); setVadLabel(null);
setSensorimotorLabel(null); setGlasgowLabel(null); setDefinitionText(null);
setDefinitionDone(false); setConceptnetHints([]); setLastResult(null);
setLatestGuessWord(null); setShowGiveUp(false); setGameError("");
setHasDefinition(false); setHasConcreteness(false); setHasVad(false);
setHasSensorimotor(false); setHasGlasgow(false);
setHasConceptnet(false); setHasCategories(false);
};
const connectWs = (code, name, reconnectPlayerId = null, isReconnect = false) => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const pidPart = reconnectPlayerId ? `&player_id=${encodeURIComponent(reconnectPlayerId)}` : "";
const url = `${proto}//${window.location.host}/api/lobby/${code}/ws?name=${encodeURIComponent(name)}${pidPart}`;
const socket = new WebSocket(url);
let opened = false;
socket.onopen = () => {
opened = true;
setLobbyCode(code);
setMyName(name);
setError("");
};
socket.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case "welcome":
setMyName(msg.name);
setHost(msg.host);
setIsHost(msg.name === msg.host);
setPlayers(msg.players);
setLobbyCode(msg.code);
setPlayerId(msg.player_id || reconnectPlayerId || null);
if (msg.player_id || reconnectPlayerId) {
const sess = { code: msg.code, name: msg.name, playerId: msg.player_id || reconnectPlayerId };
sessionRef.current = sess;
onSessionSave?.(sess);
}
break;
case "player_joined":
setPlayers(msg.players);
if (msg.host) setHost(msg.host);
break;
case "player_left":
setPlayers(msg.players);
if (msg.host) setHost(msg.host);
break;
case "game_started":
resetGameState();
setInGame(true);
setSeed(msg.seed);
setVocabSize(msg.vocab_size);
setHasDefinition(msg.has_definition || false);
setHasConcreteness(msg.has_concreteness || false);
setHasVad(msg.has_vad || false);
setHasSensorimotor(msg.has_sensorimotor || false);
setHasGlasgow(msg.has_glasgow || false);
setHasConceptnet(msg.has_conceptnet || false);
setHasCategories(msg.has_categories || false);
break;
case "guess_result":
if (msg.duplicate) {
setLastResult({ word: msg.word, score: msg.score, rank: msg.rank, isDuplicate: true, player: msg.player });
} else {
setGuesses(prev => [...prev, { word: msg.word, score: msg.score, rank: msg.rank, isHint: false, player: msg.player, guessNum: msg.guess_num }]);
setLatestGuessWord(msg.word);
setLastResult({ word: msg.word, score: msg.score, rank: msg.rank, isDuplicate: false, player: msg.player });
setGuessCount(prev => Math.max(prev, msg.guess_num || 0));
}
break;
case "hint_result":
if (!msg.already_guessed) {
setGuesses(prev => [...prev, { word: msg.word, score: msg.score, rank: msg.rank, isHint: true, player: msg.player }]);
}
setLatestGuessWord(msg.word);
setHintsUsed(msg.hints_used);
setTotalHints(msg.total_hints != null ? msg.total_hints : (prev => prev + 1));
break;
case "hint_pos":
setPosRevealed(msg.pos);
if (msg.total_hints != null) setTotalHints(msg.total_hints);
break;
case "hint_category":
if (msg.categories) setCategoryHints(msg.categories);
else setCategoryHints(prev => [...prev, msg.category]);
if (msg.has_categories !== undefined) setHasCategories(msg.has_categories);
if (msg.total_hints != null) setTotalHints(msg.total_hints);
break;
case "hint_definition":
setDefinitionText(msg.definition);
setDefinitionDone(msg.done);
if (msg.total_hints != null) setTotalHints(msg.total_hints);
break;
case "hint_concreteness":
setConcretenessLabel(msg.label);
if (msg.total_hints != null) setTotalHints(msg.total_hints);
break;
case "hint_vad":
setVadLabel(msg.label);
if (msg.total_hints != null) setTotalHints(msg.total_hints);
break;
case "hint_sensorimotor":
setSensorimotorLabel(msg.label);
if (msg.total_hints != null) setTotalHints(msg.total_hints);
break;
case "hint_glasgow":
setGlasgowLabel(msg.label);
if (msg.total_hints != null) setTotalHints(msg.total_hints);
break;
case "hint_conceptnet":
setConceptnetHints(prev => {
if (prev.some(c => c.relation === msg.relation)) return prev;
return [...prev, { relation: msg.relation, values: msg.values }];
});
if (msg.has_more !== undefined) setHasConceptnet(msg.has_more);
if (msg.total_hints != null) setTotalHints(msg.total_hints);
break;
case "game_over":
setSolved(true);
setSecretWord(msg.word);
setWinner(msg.winner);
if (msg.guesses != null) setGuessCount(msg.guesses);
break;
case "gave_up":
setGaveUp(true);
setSecretWord(msg.word);
break;
case "error":
setGameError(msg.message);
setTimeout(() => setGameError(""), 3000);
break;
}
};
socket.onclose = (event) => {
if (wsRef.current === socket) {
wsRef.current = null;
setWs(null);
}
if (manualLeaveRef.current || !reconnectEnabledRef.current) return;
if (event && event.code === 4004) {
reconnectEnabledRef.current = false;
sessionRef.current = null;
onSessionClear?.();
setLobbyCode(null);
setError("Lobby no longer exists.");
return;
}
const sess = sessionRef.current;
if (sess) {
reconnectTimerRef.current = setTimeout(() => {
connectWs(sess.code, sess.name, sess.playerId, true);
}, 900);
return;
}
if (!opened && !isReconnect) setError("Connection failed. Check the lobby code.");
};
setWs(socket);
wsRef.current = socket;
return socket;
};
// Update isHost when host/myName changes
useEffect(() => {
setIsHost(myName === host);
}, [myName, host]);
useEffect(() => {
if (initialSession) {
sessionRef.current = initialSession;
}
}, [initialSession]);
useEffect(() => {
if (initialSession && !wsRef.current) {
setMode(null);
manualLeaveRef.current = false;
reconnectEnabledRef.current = true;
connectWs(initialSession.code, initialSession.name || "Player", initialSession.playerId || null, true);
}
}, [initialSession]);
useEffect(() => {
return () => {
reconnectEnabledRef.current = false;
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
try { wsRef.current?.close(); } catch {}
};
}, []);
// Focus input when in game
useEffect(() => {
if (inGame && !solved && !gaveUp && !loading && !('ontouchstart' in window)) {
inputRef.current?.focus();
}
});
const sendMsg = (msg) => {
const socket = wsRef.current;
if (socket && socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(msg));
};
const leaveLobbyToMainMenu = () => {
if (!showLeaveLobbyConfirm) {
setShowLeaveLobbyConfirm(true);
return;
}
manualLeaveRef.current = true;
reconnectEnabledRef.current = false;
try {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "leave_lobby" }));
}
} catch {}
try { wsRef.current?.close(); } catch {}
wsRef.current = null;
setWs(null);
setLobbyCode(null);
setInGame(false);
setPlayers([]);
setHost("");
setMyName("");
setPlayerId(null);
setShowLeaveLobbyConfirm(false);
sessionRef.current = null;
onSessionClear?.();
onBack();
};
const handleCreate = async () => {
const name = nameInput.trim();
if (!name) { setError("Enter your name"); return; }
setError("");
manualLeaveRef.current = false;
reconnectEnabledRef.current = true;
setShowLeaveLobbyConfirm(false);
try {
const res = await fetch(`${API_BASE}/api/lobby/create`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ host_name: name, difficulty }),
});
const data = await res.json();
connectWs(data.code, name, data.player_id || null);
} catch { setError("Failed to create lobby"); }
};
const handleJoin = () => {
const name = nameInput.trim();
const code = codeInput.trim().toUpperCase();
if (!name) { setError("Enter your name"); return; }
if (!code || code.length < 4) { setError("Enter a valid lobby code"); return; }
setError("");
manualLeaveRef.current = false;
reconnectEnabledRef.current = true;
setShowLeaveLobbyConfirm(false);
connectWs(code, name, null);
};
const handleGuess = () => {
const word = input.trim().toLowerCase().replace(/[^a-z]/g, "");
if (!word) return;
setInput("");
setGameError("");
setShowLeaveLobbyConfirm(false);
sendMsg({ type: "guess", word });
};
const sortedGuesses = [...guesses].sort((a, b) => b.score - a.score);
const topScore = sortedGuesses[0]?.score || 0;
const gameOver = solved || gaveUp;
const revealedHintLabels = buildHintTypeLabels({
posRevealed,
categoryHints,
definitionText,
definitionDone,
concretenessLabel,
vadLabel,
sensorimotorLabel,
glasgowLabel,
conceptnetHints,
hintsUsed,
});
const btnStyle = {
background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.15)",
borderRadius: 8, padding: "12px 24px", color: "#e8e6e1", fontSize: 15,
fontFamily: "'DM Mono', monospace", cursor: "pointer", letterSpacing: "0.05em",
transition: "all 0.2s",
};
const smallBtnStyle = {
...btnStyle, padding: "8px 16px", fontSize: 13,
};
// --- Not connected yet: show create/join UI ---
if (!lobbyCode) {
const recovering = Boolean(initialSession && initialSession.code && initialSession.name);
return (
<div style={{ animation: "slideIn 0.3s ease" }}>
{recovering && (
<div style={{ textAlign: "center", padding: "12px 0 20px" }}>
<div style={{ color: "#888", fontSize: 13, fontFamily: "'DM Mono', monospace", marginBottom: 10 }}>
reconnecting to lobby...
</div>
<button
onClick={() => {
manualLeaveRef.current = true;
reconnectEnabledRef.current = false;
sessionRef.current = null;
onSessionClear?.();
onBack();
}}
style={{ ...smallBtnStyle, background: "transparent", color: "#888" }}
>
Main menu
</button>
</div>
)}
{!mode && !recovering && (
<div style={{ display: "flex", flexDirection: "column", gap: 12, alignItems: "center" }}>
<button onClick={() => setMode("create")} style={btnStyle}>Create Lobby</button>
<button onClick={() => setMode("join")} style={btnStyle}>Join Lobby</button>
<button
onClick={() => {
manualLeaveRef.current = true;
reconnectEnabledRef.current = false;
try { wsRef.current?.close(); } catch {}
wsRef.current = null;
setWs(null);
onSessionClear?.();
onBack();
}}
style={{ ...btnStyle, background: "transparent", color: "#666", fontSize: 12, padding: "8px 16px" }}
>
Back to solo
</button>
</div>
)}
{mode && !recovering && (
<div style={{ display: "flex", flexDirection: "column", gap: 10, maxWidth: 300, margin: "0 auto" }}>
<input value={nameInput} onChange={e => setNameInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && (mode === "create" ? handleCreate() : handleJoin())}
placeholder="Your name" maxLength={20}
style={{ background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 6, padding: "10px 14px", color: "#e8e6e1", fontSize: 14,
fontFamily: "'DM Mono', monospace", outline: "none" }} />
{mode === "join" && (
<input value={codeInput} onChange={e => setCodeInput(e.target.value.toUpperCase())}
onKeyDown={e => e.key === "Enter" && handleJoin()}
placeholder="Lobby code" maxLength={6}
style={{ background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 6, padding: "10px 14px", color: "#e8e6e1", fontSize: 14,
fontFamily: "'DM Mono', monospace", outline: "none", letterSpacing: "0.2em", textAlign: "center" }} />
)}
{mode === "create" && (
<div style={{ display: "flex", gap: 6, justifyContent: "center" }}>
{[{ label: "Easy", value: "easy" }, { label: "Med", value: "medium" }, { label: "Hard", value: "hard" }, { label: "Any", value: null }].map(d => (
<button key={d.label} onClick={() => setDifficulty(d.value)}
style={{ ...smallBtnStyle, padding: "6px 12px", fontSize: 12,
background: difficulty === d.value ? "rgba(255,255,255,0.12)" : "transparent",
color: difficulty === d.value ? "#e8e6e1" : "#666" }}>
{d.label}
</button>
))}
</div>
)}
<button onClick={mode === "create" ? handleCreate : handleJoin} style={btnStyle}>
{mode === "create" ? "Create" : "Join"}
</button>
<button onClick={() => { setMode(null); setError(""); }} style={{ ...btnStyle, background: "transparent", color: "#666", fontSize: 12, padding: "6px" }}>
Back
</button>
{error && <div style={{ color: "#f97316", fontSize: 13, fontFamily: "'DM Mono', monospace", textAlign: "center" }}>{error}</div>}
</div>
)}
</div>
);
}
// --- In lobby, game not started ---
if (!inGame) {
return (
<div style={{ textAlign: "center", animation: "slideIn 0.3s ease" }}>
<div style={{ color: "#666", fontSize: 12, fontFamily: "'DM Mono', monospace", marginBottom: 8 }}>lobby code</div>
<div style={{
fontSize: 36, fontWeight: 900, fontFamily: "'DM Mono', monospace",
letterSpacing: "0.3em", color: "#e8e6e1", marginBottom: 8,
userSelect: "all",
}}>{lobbyCode}</div>
<button onClick={() => {
navigator.clipboard.writeText(lobbyCode).then(() => { setCodeCopied(true); setTimeout(() => setCodeCopied(false), 2000); });
}} style={{ ...smallBtnStyle, marginBottom: 20, color: codeCopied ? "#22c55e" : "#888" }}>
{codeCopied ? "Copied!" : "Copy code"}
</button>
<div style={{ color: "#888", fontSize: 13, fontFamily: "'DM Mono', monospace", marginBottom: 12 }}>
Players ({players.length})
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, justifyContent: "center", marginBottom: 24 }}>
{players.map(p => (
<span key={p} style={{
background: "rgba(255,255,255,0.06)", border: `1px solid ${playerColor(p, players)}44`,
borderRadius: 16, padding: "6px 14px", fontSize: 13,
fontFamily: "'DM Mono', monospace", color: playerColor(p, players),
}}>
{p}{p === host ? " (host)" : ""}{p === myName ? " (you)" : ""}
</span>
))}
</div>
{isHost ? (
<div style={{ display: "flex", flexDirection: "column", gap: 12, alignItems: "center" }}>
<button onClick={() => sendMsg({ type: "start_game", difficulty, start_hints: lobbyStartHints })} style={{ ...btnStyle, background: "rgba(34,197,94,0.15)", border: "1px solid rgba(34,197,94,0.3)", color: "#22c55e" }}>
Start Game
</button>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<label style={{ color: "#666", fontSize: 12, fontFamily: "'DM Mono', monospace" }}>start with word hints:</label>
<input type="number" min="0" max="11" value={lobbyStartHints}
onChange={e => setLobbyStartHints(Math.max(0, Math.min(11, parseInt(e.target.value) || 0)))}
style={{ width: 48, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.1)", borderRadius: 6, padding: "6px 8px", color: "#e8e6e1", fontSize: 14, fontFamily: "'DM Mono', monospace", outline: "none", textAlign: "center" }} />
</div>
</div>
) : (
<div style={{ color: "#666", fontSize: 13, fontFamily: "'DM Mono', monospace" }}>
Waiting for host to start...
</div>
)}
<button
onClick={leaveLobbyToMainMenu}
style={{
...smallBtnStyle,
marginTop: 16,
background: showLeaveLobbyConfirm ? "rgba(239,68,68,0.1)" : "transparent",
border: showLeaveLobbyConfirm ? "1px solid rgba(239,68,68,0.35)" : "1px solid rgba(255,255,255,0.12)",
color: showLeaveLobbyConfirm ? "#ef4444" : "#888",
}}
>
{showLeaveLobbyConfirm ? "Confirm main menu?" : "Main menu"}
</button>
</div>
);
}
// --- In game (multiplayer) ---
return (
<div style={{ width: "100%" }}>
{/* Player list header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, marginBottom: 12 }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{players.map(p => (
<span key={p} style={{
fontSize: 11, fontFamily: "'DM Mono', monospace",
color: playerColor(p, players), opacity: 0.8,
}}>
{p}{p === myName ? " (you)" : ""}
</span>
))}
<span style={{ fontSize: 11, fontFamily: "'DM Mono', monospace", color: "#555" }}>
| {lobbyCode}
</span>
</div>
<button
onClick={leaveLobbyToMainMenu}
style={{
background: showLeaveLobbyConfirm ? "rgba(239,68,68,0.1)" : "transparent",
border: showLeaveLobbyConfirm ? "1px solid rgba(239,68,68,0.35)" : "1px solid rgba(255,255,255,0.12)",
borderRadius: 6,
padding: "6px 10px",
color: showLeaveLobbyConfirm ? "#ef4444" : "#888",
fontSize: 11,
fontFamily: "'DM Mono', monospace",
cursor: "pointer",
}}
>
{showLeaveLobbyConfirm ? "Confirm?" : "Main menu"}
</button>
</div>
{/* Stats */}
<div className="stats-bar" style={{
display: "flex", justifyContent: "center", gap: 32, marginBottom: 24, padding: "12px 0",
borderTop: "1px solid rgba(255,255,255,0.1)", borderBottom: "1px solid rgba(255,255,255,0.1)",
}}>
{[
{ label: "Guesses", value: guessCount },
{ label: "Best", value: topScore ? topScore.toFixed(2) : "\u2014" },
{ label: "Hints", value: totalHints },
].map(s => (
<div key={s.label} style={{ textAlign: "center" }}>
<div style={{ fontSize: 20, fontWeight: 700, fontFamily: "'DM Mono', monospace",
color: s.label === "Best" && topScore >= 0.70 ? "#84cc16" : "#e8e6e1" }}>{s.value}</div>
<div style={{ fontSize: 11, color: "#666", textTransform: "uppercase", letterSpacing: "0.1em",
fontFamily: "'DM Mono', monospace", marginTop: 2 }}>{s.label}</div>
</div>
))}
</div>
{/* Revealed hints display */}
{(posRevealed || concretenessLabel || vadLabel || sensorimotorLabel || glasgowLabel || definitionText || conceptnetHints.length > 0 || categoryHints.length > 0) && !gameOver && (
<div style={{ marginBottom: 8, paddingLeft: 4 }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{posRevealed && <span style={{ background: "rgba(139,92,246,0.15)", border: "1px solid rgba(139,92,246,0.3)", borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#a78bfa", fontFamily: "'DM Mono', monospace" }}>Part of Speech: {posRevealed}</span>}
{concretenessLabel && <span style={{ background: "rgba(139,92,246,0.15)", border: "1px solid rgba(139,92,246,0.3)", borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#a78bfa", fontFamily: "'DM Mono', monospace" }}>Concreteness: {concretenessLabel}</span>}
{vadLabel && <span style={{ background: "rgba(16,185,129,0.12)", border: "1px solid rgba(16,185,129,0.3)", borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#34d399", fontFamily: "'DM Mono', monospace" }}>Emotional Tone: {vadLabel}</span>}
{sensorimotorLabel && <span style={{ background: "rgba(16,185,129,0.12)", border: "1px solid rgba(16,185,129,0.3)", borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#34d399", fontFamily: "'DM Mono', monospace" }}>Senses & Action: {sensorimotorLabel}</span>}
{glasgowLabel && <span style={{ background: "rgba(16,185,129,0.12)", border: "1px solid rgba(16,185,129,0.3)", borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#34d399", fontFamily: "'DM Mono', monospace" }}>Familiarity & Imagery: {glasgowLabel}</span>}
{conceptnetHints.map((cn, i) => <span key={`cn-${i}`} style={{ background: "rgba(234,179,8,0.1)", border: "1px solid rgba(234,179,8,0.25)", borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#eab308", fontFamily: "'DM Mono', monospace" }}>Semantic Clue: {cn.relation} ({cn.values.join(", ")})</span>)}
{categoryHints.map((cat, i) => <span key={i} style={{ background: "rgba(234,179,8,0.1)", border: "1px solid rgba(234,179,8,0.25)", borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#eab308", fontFamily: "'DM Mono', monospace" }}>Category: {cat}</span>)}
</div>
{definitionText && <div style={{ background: "rgba(59,130,246,0.08)", border: "1px solid rgba(59,130,246,0.2)", borderRadius: 8, padding: "8px 12px", marginTop: 6, fontSize: 13, color: "#93c5fd", fontFamily: "'DM Mono', monospace", lineHeight: 1.5, fontStyle: "italic" }}>{definitionText}</div>}
</div>
)}
{/* Game over */}
{gameOver && (
<div style={{ marginBottom: 16, animation: "celebrate 0.5s ease" }}>
<div style={{
background: solved ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)",
border: `1px solid ${solved ? "rgba(34,197,94,0.3)" : "rgba(239,68,68,0.3)"}`,
borderRadius: 12, padding: "20px 24px", textAlign: "center",
}}>
<div style={{ fontFamily: "'Playfair Display', serif", fontSize: 28, fontWeight: 900, color: solved ? "#22c55e" : "#ef4444", marginBottom: 4 }}>
{solved ? `${winner} solved it!` : "The word was:"}
</div>
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 20, color: "#e8e6e1", letterSpacing: "0.15em", textTransform: "uppercase", marginTop: 4 }}>
{secretWord}
</div>
{solved && <div style={{ color: "#888", fontSize: 13, marginTop: 8, fontFamily: "'DM Mono', monospace" }}>
in {guessCount} guess{guessCount !== 1 ? "es" : ""}{totalHints > 0 ? ` (${totalHints} hint${totalHints !== 1 ? "s" : ""})` : ""}
</div>}
{isHost ? (
<button onClick={() => sendMsg({ type: "new_round", difficulty, start_hints: lobbyStartHints })}
style={{ ...btnStyle, marginTop: 16, background: "rgba(34,197,94,0.15)", border: "1px solid rgba(34,197,94,0.3)", color: "#22c55e" }}>
New Round
</button>
) : (
<div style={{ color: "#666", fontSize: 13, marginTop: 12, fontFamily: "'DM Mono', monospace" }}>
Waiting for host to start next round...
</div>
)}
</div>
</div>
)}
{/* Input */}
{!gameOver && (
<div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
<input ref={inputRef} type="text" value={input}
onChange={e => { setInput(e.target.value); setGameError(""); setShowGiveUp(false); setShowHintMenu(false); setShowLeaveLobbyConfirm(false); }}
onKeyDown={e => e.key === "Enter" && handleGuess()}
placeholder="type a word..." autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck="false"
style={{ flex: 1, background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8, padding: "12px 16px", color: "#e8e6e1", fontSize: 16, fontFamily: "'DM Mono', monospace", outline: "none" }}
onFocus={e => e.target.style.borderColor = "rgba(255,255,255,0.35)"}
onBlur={e => e.target.style.borderColor = "rgba(255,255,255,0.15)"} />
<button onMouseDown={e => e.preventDefault()} onClick={handleGuess} disabled={!input.trim()}
style={{ background: "rgba(255,255,255,0.12)", border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8, padding: "12px 20px", color: "#e8e6e1", fontSize: 14, fontFamily: "'DM Mono', monospace", cursor: "pointer", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Guess
</button>
</div>
)}
{gameError && <div style={{ color: "#f97316", fontSize: 13, fontFamily: "'DM Mono', monospace", marginBottom: 8, paddingLeft: 4 }}>{gameError}</div>}
{lastResult && !gameOver && (
<div style={{
display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", marginBottom: 8, borderRadius: 8,
background: "rgba(255,255,255,0.06)", border: `1px solid ${scoreColor(lastResult.score)}33`, animation: "slideIn 0.3s ease",
}}>
<span style={{ color: scoreColor(lastResult.score), fontSize: 22, fontWeight: 700, fontFamily: "'DM Mono', monospace", minWidth: 52 }}>
{lastResult.score.toFixed(2)}
</span>
<span style={{ color: "#e8e6e1", fontSize: 15, fontFamily: "'DM Mono', monospace", flex: 1 }}>
{lastResult.word}
</span>
<span style={{ color: playerColor(lastResult.player, players), fontSize: 12, fontFamily: "'DM Mono', monospace" }}>
{lastResult.player}{lastResult.isDuplicate ? " (dup)" : ""}
</span>
</div>
)}
{revealedHintLabels.length > 0 && !gameOver && (
<div className="hint-chip-row">
{revealedHintLabels.map(label => (
<span key={label} className="hint-chip">{label}</span>
))}
</div>
)}
{/* Hint menu & Give up */}
{!gameOver && (
<div style={{ marginBottom: 8, position: "relative" }}>
<div style={{ display: "flex", gap: 8 }}>
<button onMouseDown={e => e.preventDefault()}
onClick={() => { setShowHintMenu(!showHintMenu); setShowGiveUp(false); setShowLeaveLobbyConfirm(false); }}
style={{ flex: 1, background: "transparent", border: showHintMenu ? "1px solid rgba(234,179,8,0.4)" : "1px solid rgba(255,255,255,0.12)", borderRadius: 8, padding: "12px", color: showHintMenu ? "#eab308" : "#777", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>
{showHintMenu ? "Hints \u25B4" : "Hints \u25BE"}
</button>
<button onMouseDown={e => e.preventDefault()}
onClick={() => { if (!showGiveUp) { setShowGiveUp(true); setShowHintMenu(false); setShowLeaveLobbyConfirm(false); } else { sendMsg({ type: "give_up" }); } }}
style={{ flex: 1, background: "transparent", border: showGiveUp ? "1px solid rgba(239,68,68,0.4)" : "1px solid rgba(255,255,255,0.12)", borderRadius: 8, padding: "12px", color: showGiveUp ? "#ef4444" : "#777", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>
{showGiveUp ? "Confirm give up?" : "Give up"}
</button>
</div>
{showHintMenu && (
<div className="hint-panel" style={{ marginTop: 6, background: "rgba(255,255,255,0.04)", border: "1px solid rgba(255,255,255,0.12)", borderRadius: 8, padding: 6, display: "flex", flexDirection: "column", gap: 4, animation: "slideIn 0.2s ease" }}>
<div className="hint-section-title">Core</div>
<div className="hint-grid">
{hintsUsed < MAX_HINTS && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>{`Similar Word (${MAX_HINTS - hintsUsed} left)`}</button>}
{hasCategories && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_category" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>{categoryHints.length > 0 ? `Category: ${categoryHints.join(" \u2192 ")} \u2192 ???` : "Category"}</button>}
{hasDefinition && !definitionDone && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_definition" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>{definitionText ? "Reveal More Definition" : "Definition"}</button>}
{!posRevealed && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_pos" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>Part of Speech</button>}
</div>
<div className="hint-section-title">Advanced</div>
<div className="hint-grid">
{hasConcreteness && !concretenessLabel && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_concreteness" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>Concreteness</button>}
{hasVad && !vadLabel && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_vad" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>Emotional Tone</button>}
{hasSensorimotor && !sensorimotorLabel && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_sensorimotor" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>Senses & Action</button>}
{hasGlasgow && !glasgowLabel && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_glasgow" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>Familiarity & Imagery</button>}
{hasConceptnet && <button onMouseDown={e => e.preventDefault()} onClick={() => sendMsg({ type: "hint_conceptnet" })} className="hint-btn" style={{ background: "transparent", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6, padding: "10px 12px", textAlign: "left", color: "#999", fontSize: 13, fontFamily: "'DM Mono', monospace", cursor: "pointer" }}>{conceptnetHints.length > 0 ? `Semantic Clues (${conceptnetHints.length} shown)` : "Semantic Clues"}</button>}
</div>
</div>
)}
</div>
)}
{/* Guesses list (with player names) */}
{guesses.length > 0 && (
<div>
<div className="mp-guess-grid" style={{ display: "grid", gridTemplateColumns: "56px 36px 1fr 56px 56px 1fr", gap: 10, padding: "8px 16px", color: "#555", fontSize: 11, fontFamily: "'DM Mono', monospace", textTransform: "uppercase", letterSpacing: "0.1em" }}>
<span>Player</span><span>#</span><span>Word</span><span style={{ textAlign: "right" }}>Score</span><span className="rank-col" style={{ textAlign: "right" }}>Rank</span><span></span>
</div>
{sortedGuesses.map((g, i) => (
<div key={`${g.word}-${i}`} className="mp-guess-grid" style={{
display: "grid", gridTemplateColumns: "56px 36px 1fr 56px 56px 1fr", alignItems: "center", gap: 10, padding: "10px 16px",
background: i === 0 && guesses.length > 1 ? "rgba(34,197,94,0.08)" : g.word === latestGuessWord ? "rgba(255,255,255,0.03)" : "transparent",
borderLeft: i === 0 && guesses.length > 1 ? "2px solid #22c55e" : "2px solid transparent",
borderRadius: 4, animation: g.word === latestGuessWord ? "slideIn 0.3s ease" : "none",
}}>
<span style={{ color: playerColor(g.player, players), fontSize: 11, fontFamily: "'DM Mono', monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{g.player || ""}
</span>
<span style={{ color: "#666", fontSize: 13, fontFamily: "'DM Mono', monospace" }}>
{g.isHint ? "\u2605" : `#${g.guessNum || ""}`}
</span>
<span style={{ color: g.isHint ? "#eab308" : "#e8e6e1", fontSize: 15, fontFamily: "'DM Mono', monospace", textTransform: "lowercase", letterSpacing: "0.02em", fontStyle: g.isHint ? "italic" : "normal" }}>
{g.word}
</span>
<span style={{ color: scoreColor(g.score), fontSize: 15, fontWeight: 600, fontFamily: "'DM Mono', monospace", textAlign: "right" }}>
{g.score.toFixed(2)}
</span>
<span className="rank-col" style={{ color: "#555", fontSize: 12, fontFamily: "'DM Mono', monospace", textAlign: "right" }}>
{g.rank ? `#${g.rank}` : ""}
</span>
<ScoreBar score={g.score} />
</div>
))}
</div>
)}
</div>
);
}
function SemanticGuess() {
const [gameId, setGameId] = useState(null);
const [lobbyMode, setLobbyMode] = useState(false);
const [lobbySession, setLobbySession] = useState(() => {
try {
const raw = localStorage.getItem(LOBBY_SESSION_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
});
const [vocabSize, setVocabSize] = useState(0);
const [guesses, setGuesses] = useState([]);
const [guessCount, setGuessCount] = useState(0); // actual guesses (excludes hints)
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [starting, setStarting] = useState(false);
const [error, setError] = useState("");
const [solved, setSolved] = useState(false);
const [gaveUp, setGaveUp] = useState(false);
const [secretWord, setSecretWord] = useState("");
const [showGiveUp, setShowGiveUp] = useState(false);
const [latestGuessWord, setLatestGuessWord] = useState(null);
const [hintsUsed, setHintsUsed] = useState(0);
const [lastResult, setLastResult] = useState(null);
const [showHelp, setShowHelp] = useState(false);
const [seed, setSeed] = useState("");
const [showHintMenu, setShowHintMenu] = useState(false);
const [posRevealed, setPosRevealed] = useState(null);
const [categoryHints, setCategoryHints] = useState([]);
const [difficulty, setDifficulty] = useState(null);
const [totalHints, setTotalHints] = useState(0);
const [concretenessLabel, setConcretenessLabel] = useState(null);
const [vadLabel, setVadLabel] = useState(null);
const [sensorimotorLabel, setSensorimotorLabel] = useState(null);
const [glasgowLabel, setGlasgowLabel] = useState(null);
const [definitionText, setDefinitionText] = useState(null);
const [definitionDone, setDefinitionDone] = useState(false);
const [conceptnetHints, setConceptnetHints] = useState([]);
const [hasDefinition, setHasDefinition] = useState(false);
const [hasConcreteness, setHasConcreteness] = useState(false);
const [hasVad, setHasVad] = useState(false);
const [hasSensorimotor, setHasSensorimotor] = useState(false);
const [hasGlasgow, setHasGlasgow] = useState(false);
const [hasConceptnet, setHasConceptnet] = useState(false);
const [hasCategories, setHasCategories] = useState(false);
const [shareCopied, setShareCopied] = useState(false);
const [needsDifficultyPick, setNeedsDifficultyPick] = useState(false);
const [showSeedInput, setShowSeedInput] = useState(false);
const [seedInputValue, setSeedInputValue] = useState("");
const [confirmSeedLoad, setConfirmSeedLoad] = useState(false);
const [showWordSeedLookup, setShowWordSeedLookup] = useState(false);
const [wordSeedInput, setWordSeedInput] = useState("");
const [wordSeedResult, setWordSeedResult] = useState("");
const [wordSeedValue, setWordSeedValue] = useState("");
const [wordSeedLoading, setWordSeedLoading] = useState(false);
const [showMainMenuConfirm, setShowMainMenuConfirm] = useState(false);
const [startHints, setStartHints] = useState(0);
const inputRef = useRef(null);
const listRef = useRef(null);
const HINT_RANKS = [1000, 100, 10, 9, 8, 7, 6, 5, 4, 3, 2];
const MAX_HINTS = HINT_RANKS.length;
const resetState = () => {
setGuesses([]);
setGuessCount(0);
setSolved(false);
setGaveUp(false);
setSecretWord("");
setError("");
setShowGiveUp(false);
setLatestGuessWord(null);
setHintsUsed(0);
setLastResult(null);
setSeed("");
setShowHintMenu(false);
setPosRevealed(null);
setCategoryHints([]);
setShareCopied(false);
setTotalHints(0);
setConcretenessLabel(null);
setVadLabel(null);
setSensorimotorLabel(null);
setGlasgowLabel(null);
setDefinitionText(null);
setDefinitionDone(false);
setConceptnetHints([]);
setHasDefinition(false);
setHasConcreteness(false);
setHasVad(false);
setHasSensorimotor(false);
setHasGlasgow(false);
setHasConceptnet(false);
setHasCategories(false);
setShowMainMenuConfirm(false);
setShowWordSeedLookup(false);
setWordSeedInput("");
setWordSeedResult("");
setWordSeedValue("");
setWordSeedLoading(false);
};
const saveLobbySession = useCallback((session) => {
setLobbySession(session);
try {
if (session) localStorage.setItem(LOBBY_SESSION_KEY, JSON.stringify(session));
else localStorage.removeItem(LOBBY_SESSION_KEY);
} catch {}
}, []);
const goToMainMenu = () => {
if (!showMainMenuConfirm) { setShowMainMenuConfirm(true); return; }
resetState();
setGameId(null);
setVocabSize(0);
setNeedsDifficultyPick(true);
setLobbyMode(false);
saveLobbySession(null);
setShowMainMenuConfirm(false);
localStorage.removeItem("semantick_game_id");
if (window.location.search) {
window.history.replaceState({}, "", window.location.pathname);
}
};
const restoreGame = (gameId, state) => {
setGameId(gameId);
setVocabSize(state.vocab_size);
setSeed(state.seed);
setHintsUsed(state.hints_used);
setSolved(state.solved);
setGaveUp(state.gave_up);
if (state.secret_word) setSecretWord(state.secret_word);
if (state.pos_revealed && state.pos) setPosRevealed(state.pos);
if (state.category_hints) setCategoryHints(state.category_hints);
if (state.difficulty) setDifficulty(state.difficulty);
if (state.total_hints != null) setTotalHints(state.total_hints);
if (state.definition) setDefinitionText(state.definition);
if (state.definition_done) setDefinitionDone(true);
if (state.concreteness_label) setConcretenessLabel(state.concreteness_label);
if (state.vad_label) setVadLabel(state.vad_label);
if (state.sensorimotor_label) setSensorimotorLabel(state.sensorimotor_label);
if (state.glasgow_label) setGlasgowLabel(state.glasgow_label);
if (state.conceptnet_hints) setConceptnetHints(state.conceptnet_hints);
setHasDefinition(state.has_definition || false);
setHasConcreteness(state.has_concreteness || false);
setHasVad(state.has_vad || false);
setHasSensorimotor(state.has_sensorimotor || false);
setHasGlasgow(state.has_glasgow || false);
setHasConceptnet(state.has_conceptnet || false);
setHasCategories(state.has_categories || false);
// Restore guesses from server state
let gc = 0;
const restored = state.guesses.map(g => {
if (!g.isHint) gc++;
return { word: g.word, score: g.score, rank: g.rank, isHint: g.isHint, guessNum: g.isHint ? null : gc };
});
setGuesses(restored);
setGuessCount(gc);
};
const fireAutoHints = async (gid, count) => {
if (!count || count < 1) return;
for (let i = 0; i < count; i++) {
try {
const res = await fetch(`${API_BASE}/api/game/${gid}/hint`, { method: "POST" });
if (!res.ok) break;
const d = await res.json();
if (!d.already_guessed) {
setGuesses(prev => [...prev, { word: d.word, score: d.score, rank: d.rank, isHint: true }]);
}
setLatestGuessWord(d.word);
setHintsUsed(d.hints_used);
setTotalHints(prev => prev + 1);
} catch {
break;
}
}
// Re-fetch availability flags after auto-hints
try {
const stateRes = await fetch(`${API_BASE}/api/game/${gid}`);
if (stateRes.ok) {
const state = await stateRes.json();
setHasDefinition(state.has_definition || false);
setHasConcreteness(state.has_concreteness || false);
setHasVad(state.has_vad || false);
setHasSensorimotor(state.has_sensorimotor || false);
setHasGlasgow(state.has_glasgow || false);
setHasConceptnet(state.has_conceptnet || false);
setHasCategories(state.has_categories || false);
}
} catch {}
};
const startNewGame = useCallback(async (forceSeed, overrideDifficulty, hintCount) => {
setStarting(true);
setNeedsDifficultyPick(false);
resetState();
// Clear seed from URL when starting a fresh game without a forced seed
if (!forceSeed && window.location.search) {
window.history.replaceState({}, "", window.location.pathname);
}
const diff = overrideDifficulty !== undefined
? overrideDifficulty
: (forceSeed ? null : difficulty);
try {
const params = new URLSearchParams();
if (forceSeed) params.set("seed", forceSeed);
if (diff) params.set("difficulty", diff);
const qs = params.toString() ? `?${params.toString()}` : "";
const res = await fetch(`${API_BASE}/api/new-game${qs}`, { method: "POST" });
const data = await res.json();
setGameId(data.game_id);
setVocabSize(data.vocab_size);
setSeed(data.seed);
setShowSeedInput(false);
setConfirmSeedLoad(false);
setSeedInputValue("");
localStorage.setItem("semantick_game_id", data.game_id);
// Fetch game state to get availability flags
try {
const stateRes = await fetch(`${API_BASE}/api/game/${data.game_id}`);
if (stateRes.ok) {
const state = await stateRes.json();
setHasDefinition(state.has_definition || false);
setHasConcreteness(state.has_concreteness || false);
setHasVad(state.has_vad || false);
setHasSensorimotor(state.has_sensorimotor || false);
setHasGlasgow(state.has_glasgow || false);
setHasConceptnet(state.has_conceptnet || false);
setHasCategories(state.has_categories || false);
}
} catch {}
// Update URL with seed for sharing
if (data.seed) {
const newUrl = `${window.location.pathname}?seed=${encodeURIComponent(data.seed)}`;
window.history.replaceState({}, "", newUrl);
}
// Fire auto-hints if requested
const autoHints = hintCount !== undefined ? hintCount : startHints;
if (autoHints > 0) {
await fireAutoHints(data.game_id, autoHints);
}
} catch {
setError("Can't connect to server. Is it running?");
}
setStarting(false);
inputRef.current?.focus();
}, [difficulty, startHints]);
const lookupWordSeed = useCallback(async () => {
const word = wordSeedInput.trim().toLowerCase();
if (!word) {
setWordSeedResult("enter a word");
setWordSeedValue("");
return;
}
setWordSeedLoading(true);
setWordSeedResult("");
setWordSeedValue("");
try {
const params = new URLSearchParams();
params.set("word", word);
const res = await fetch(`${API_BASE}/api/seed-for-word?${params.toString()}`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
setWordSeedResult(err.detail || "lookup failed");
return;
}
const data = await res.json();
if (data.found && data.seed) {
setWordSeedValue(data.seed);
setWordSeedResult(`seed: ${data.seed}`);
} else {
setWordSeedResult(data.message || "word not in secret words list");
}
} catch {
setWordSeedResult("lookup failed");
} finally {
setWordSeedLoading(false);
}
}, [wordSeedInput]);
// On mount: try to resume from localStorage, or check URL seed, or start new
useEffect(() => {
const init = async () => {
setStarting(true);
if (lobbySession && lobbySession.code && lobbySession.name) {
setLobbyMode(true);
setNeedsDifficultyPick(false);
setStarting(false);
return;
}
// Check URL for seed parameter
const urlSeed = new URLSearchParams(window.location.search).get("seed");
// Try to resume existing game (only if no seed in URL)
if (!urlSeed) {
const savedGameId = localStorage.getItem("semantick_game_id");
if (savedGameId) {
try {
const res = await fetch(`${API_BASE}/api/game/${savedGameId}`);
if (res.ok) {
const state = await res.json();
restoreGame(savedGameId, state);
setStarting(false);
return;
}
} catch {}
// Game expired or server restarted, clear and start fresh
localStorage.removeItem("semantick_game_id");
}
}
setStarting(false);
if (urlSeed) {
startNewGame(urlSeed);
} else {
setNeedsDifficultyPick(true);
}
};
init();
}, []);
// Keep input focused after every state change (desktop)
useEffect(() => {
if (!solved && !gaveUp && !loading && !starting) {
// Only auto-focus on non-touch devices to avoid mobile keyboard popup
if (!('ontouchstart' in window)) {
inputRef.current?.focus();
}
}
});
// Keyboard shortcut: Enter to start new game when game is over
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === "Enter" && (solved || gaveUp)) {
e.preventDefault();
startNewGame();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [solved, gaveUp, startNewGame]);
const refocusInput = () => {
// Re-focus input after actions, even on mobile, if it was already focused
setTimeout(() => inputRef.current?.focus(), 0);
};
const handleGuess = async () => {
const word = input.trim().toLowerCase().replace(/[^a-z]/g, "");
if (!word || !gameId) return;
const existingIdx = guesses.findIndex(g => g.word === word);
if (existingIdx !== -1) {
setLatestGuessWord(word);
setLastResult({ word, score: guesses[existingIdx].score, rank: guesses[existingIdx].rank, isDuplicate: true });
setError("");
setInput("");
refocusInput();
return;
}
setInput("");
setError("");
setLoading(true);
try {
const res = await fetch(`${API_BASE}/api/game/${gameId}/guess`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ word }),
});
if (res.status === 422) {
const data = await res.json();
setError(`"${word}" ${data.detail || "is not in the dictionary"}`);
setLoading(false);
return;
}
if (!res.ok) throw new Error("Server error");
const data = await res.json();
const newGuessCount = guessCount + 1;
setGuessCount(newGuessCount);
setGuesses(prev => [...prev, {
word: data.word,
score: data.score,
rank: data.rank,
guessNum: newGuessCount,
}]);
setLatestGuessWord(data.word);
setLastResult({ word: data.word, score: data.score, rank: data.rank, isDuplicate: false });
if (data.solved) {
setSolved(true);
setSecretWord(data.word);
}
} catch {
setError("Request failed. Check server connection.");
}
setLoading(false);
refocusInput();
};
const handleGiveUp = async () => {
if (!showGiveUp) { setShowGiveUp(true); return; }
try {
const res = await fetch(`${API_BASE}/api/game/${gameId}/give-up`, { method: "POST" });
const data = await res.json();
setSecretWord(data.secret_word);
setGaveUp(true);
} catch {
setError("Request failed.");
}
};
const handleHint = async () => {
try {
const res = await fetch(`${API_BASE}/api/game/${gameId}/hint`, { method: "POST" });
if (!res.ok) throw new Error("Server error");
const data = await res.json();
setHintsUsed(data.hints_used);
setTotalHints(prev => prev + 1);
if (data.already_guessed) {
// Word already in the list - just highlight it
setLatestGuessWord(data.word);
setLastResult({ word: data.word, score: data.score, rank: data.rank, isDuplicate: true });
} else {
setGuesses(prev => [...prev, {
word: data.word,
score: data.score,
rank: data.rank,
isHint: true,
}]);
setLatestGuessWord(data.word);
}
} catch {
setError("Request failed.");
}
};
const sortedGuesses = [...guesses].sort((a, b) => b.score - a.score);
const topScore = sortedGuesses[0]?.score || 0;
const gameOver = solved || gaveUp;
const revealedHintLabels = buildHintTypeLabels({
posRevealed,
categoryHints,
definitionText,
definitionDone,
concretenessLabel,
vadLabel,
sensorimotorLabel,
glasgowLabel,
conceptnetHints,
hintsUsed,
});
return (
<div style={{
minHeight: "100vh", background: "#1a1a19", color: "#e8e6e1",
fontFamily: "'DM Sans', 'Helvetica Neue', sans-serif",
display: "flex", flexDirection: "column", alignItems: "center",
}}>
<style>{`
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;700&family=Playfair+Display:wght@700;900&display=swap');
@keyframes slideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
@keyframes celebrate {
0% { transform: scale(1); }
50% { transform: scale(1.02); }
100% { transform: scale(1); }
}
@keyframes loadSlide {
0% { transform: translateX(-120px); }
100% { transform: translateX(300px); }
}
input::placeholder { color: #555; }
* { box-sizing: border-box; }
.hint-panel {
max-height: 52vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.hint-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.hint-section-title {
color: #666;
font-size: 11px;
font-family: 'DM Mono', monospace;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 2px 2px 4px;
}
.hint-chip-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.hint-chip {
background: rgba(234,179,8,0.08);
border: 1px solid rgba(234,179,8,0.2);
border-radius: 999px;
padding: 3px 9px;
color: #b9b9b9;
font-size: 11px;
font-family: 'DM Mono', monospace;
}
/* Mobile responsive overrides */
@media (max-width: 480px) {
.guess-grid { grid-template-columns: 28px 1fr 48px 1fr !important; gap: 6px !important; padding: 8px 10px !important; }
.guess-grid .rank-col { display: none !important; }
.mp-guess-grid { grid-template-columns: 44px 28px 1fr 48px 1fr !important; gap: 6px !important; padding: 8px 8px !important; }
.mp-guess-grid .rank-col { display: none !important; }
.stats-bar { gap: 16px !important; }
.main-wrap { padding-top: 24px !important; }
.hint-btn { min-height: 44px !important; display: flex !important; align-items: center !important; }
.hint-grid { grid-template-columns: 1fr !important; }
.hint-panel { max-height: 58vh !important; }
}
`}</style>
<div className="main-wrap" style={{ width: "100%", maxWidth: 600, padding: "40px 24px 0" }}>
{/* Header */}
<div style={{ textAlign: "center", marginBottom: 32 }}>
<h1 style={{
fontFamily: "'Playfair Display', serif", fontSize: 36, fontWeight: 900,
margin: 0, letterSpacing: "-0.02em",
background: "linear-gradient(135deg, #e8e6e1 0%, #a8a49c 100%)",
WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent",
}}>
Semantick
</h1>
<p style={{
color: "#666", fontSize: 13, marginTop: 6,
fontFamily: "'DM Mono', monospace",
letterSpacing: "0.08em", textTransform: "uppercase",
}}>
guess the word by meaning
</p>
</div>
{/* Loading indicator */}
{starting && !gameId && !needsDifficultyPick && !lobbyMode && (
<div style={{
textAlign: "center", padding: "40px 0",
animation: "pulse 1.5s infinite",
}}>
<div style={{
color: "#666", fontSize: 14, fontFamily: "'DM Mono', monospace",
letterSpacing: "0.08em",
}}>loading...</div>
<div style={{
width: 120, height: 3, background: "rgba(255,255,255,0.06)",
borderRadius: 2, margin: "16px auto 0", overflow: "hidden",
}}>
<div style={{
width: "40%", height: "100%", background: "rgba(255,255,255,0.2)",
borderRadius: 2, animation: "loadSlide 1.2s ease-in-out infinite",
}} />
</div>
</div>
)}
{/* Lobby mode */}
{lobbyMode && (
<LobbyScreen
initialSession={lobbySession}
onSessionSave={saveLobbySession}
onSessionClear={() => saveLobbySession(null)}
onBack={() => {
setLobbyMode(false);
setNeedsDifficultyPick(true);
}}
/>
)}
{/* How to play */}
{!lobbyMode && <HowToPlay show={showHelp} onToggle={() => setShowHelp(!showHelp)} />}
{/* Stats */}
{!needsDifficultyPick && !lobbyMode && <div className="stats-bar" style={{
display: "flex", justifyContent: "center", gap: 32,
marginBottom: 24, padding: "12px 0",
borderTop: "1px solid rgba(255,255,255,0.1)",
borderBottom: "1px solid rgba(255,255,255,0.1)",
}}>
{[
{ label: "Guesses", value: guessCount },
{ label: "Best", value: topScore ? topScore.toFixed(2) : "\u2014" },
{ label: "Hints", value: totalHints },
{ label: "Pool", value: vocabSize ? `${Math.round(vocabSize / 1000)}k` : "\u2014" },
].map(s => (
<div key={s.label} style={{ textAlign: "center" }}>
<div style={{
fontSize: 20, fontWeight: 700,
fontFamily: "'DM Mono', monospace",
color: s.label === "Best" && topScore >= 0.70 ? "#84cc16" : "#e8e6e1"
}}>{s.value}</div>
<div style={{
fontSize: 11, color: "#666", textTransform: "uppercase",
letterSpacing: "0.1em", fontFamily: "'DM Mono', monospace", marginTop: 2
}}>{s.label}</div>
</div>
))}
</div>}
{/* Seed display & share (mid-game) */}
{seed && !needsDifficultyPick && !lobbyMode && (
<div style={{
display: "flex", alignItems: "center", justifyContent: "center",
gap: 8, marginBottom: 12, flexWrap: "wrap",
}}>
<span style={{
color: "#555", fontSize: 12, fontFamily: "'DM Mono', monospace",
}}>
Seed: {seed.length > 16 ? seed.slice(0, 16) + "\u2026" : seed}
</span>
<button
onMouseDown={e => e.preventDefault()}
onClick={() => {
const url = `${window.location.origin}${window.location.pathname}?seed=${encodeURIComponent(seed)}`;
navigator.clipboard.writeText(url).then(() => {
setShareCopied(true);
setTimeout(() => setShareCopied(false), 2000);
});
}}
style={{
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.1)", borderRadius: 6,
padding: "6px 12px", color: shareCopied ? "#22c55e" : "#888", fontSize: 12,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
transition: "all 0.2s", letterSpacing: "0.03em",
}}
>
{shareCopied ? "Copied!" : "Share"}
</button>
<button
onMouseDown={e => e.preventDefault()}
onClick={() => { setShowSeedInput(v => !v); setConfirmSeedLoad(false); setSeedInputValue(""); }}
style={{
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.1)", borderRadius: 6,
padding: "6px 12px", color: "#888", fontSize: 12,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
transition: "all 0.2s", letterSpacing: "0.03em",
}}
>
Load seed
</button>
<button
onMouseDown={e => e.preventDefault()}
onClick={() => {
setShowWordSeedLookup(v => !v);
setWordSeedInput("");
setWordSeedResult("");
setWordSeedValue("");
}}
style={{
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.1)", borderRadius: 6,
padding: "6px 12px", color: "#888", fontSize: 12,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
transition: "all 0.2s", letterSpacing: "0.03em",
}}
>
Word to seed
</button>
<button
onMouseDown={e => e.preventDefault()}
onClick={goToMainMenu}
style={{
background: showMainMenuConfirm ? "rgba(239,68,68,0.1)" : "rgba(255,255,255,0.06)",
border: showMainMenuConfirm ? "1px solid rgba(239,68,68,0.3)" : "1px solid rgba(255,255,255,0.1)", borderRadius: 6,
padding: "6px 12px", color: showMainMenuConfirm ? "#ef4444" : "#888", fontSize: 12,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
transition: "all 0.2s", letterSpacing: "0.03em",
}}
>
{showMainMenuConfirm ? "Leave game?" : "Menu"}
</button>
</div>
)}
{/* Seed input */}
{showSeedInput && (
<div style={{
display: "flex", gap: 6, marginBottom: 12, alignItems: "center",
}}>
<input
type="text"
value={seedInputValue}
onChange={e => { setSeedInputValue(e.target.value); setConfirmSeedLoad(false); }}
placeholder="paste a seed..."
style={{
flex: 1, background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.1)", borderRadius: 6,
padding: "8px 12px", color: "#e8e6e1", fontSize: 13,
fontFamily: "'DM Mono', monospace", outline: "none",
}}
/>
<button
onMouseDown={e => e.preventDefault()}
onClick={() => {
if (!confirmSeedLoad) { setConfirmSeedLoad(true); return; }
const s = seedInputValue.trim();
if (s) { setShowSeedInput(false); startNewGame(s); }
}}
disabled={!seedInputValue.trim()}
style={{
background: "rgba(255,255,255,0.06)",
border: `1px solid ${confirmSeedLoad ? "rgba(249,115,22,0.4)" : "rgba(255,255,255,0.1)"}`,
borderRadius: 6, padding: "4px 10px",
color: confirmSeedLoad ? "#f97316" : "#888", fontSize: 12,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
transition: "all 0.2s",
}}
>
{confirmSeedLoad ? "Confirm?" : "Go"}
</button>
</div>
)}
{/* Secret-word to seed lookup */}
{showWordSeedLookup && !lobbyMode && (
<div style={{
display: "flex", gap: 6, marginBottom: 12, alignItems: "center", flexWrap: "wrap",
}}>
<input
type="text"
value={wordSeedInput}
onChange={e => { setWordSeedInput(e.target.value); setWordSeedResult(""); setWordSeedValue(""); }}
onKeyDown={e => e.key === "Enter" && !wordSeedLoading && lookupWordSeed()}
placeholder="enter secret word..."
style={{
flex: 1, minWidth: 180, background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.1)", borderRadius: 6,
padding: "8px 12px", color: "#e8e6e1", fontSize: 13,
fontFamily: "'DM Mono', monospace", outline: "none",
}}
/>
<button
onMouseDown={e => e.preventDefault()}
onClick={lookupWordSeed}
disabled={wordSeedLoading}
style={{
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 6, padding: "6px 12px",
color: "#888", fontSize: 12,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
}}
>
{wordSeedLoading ? "..." : "Find"}
</button>
{wordSeedValue && (
<button
onMouseDown={e => e.preventDefault()}
onClick={() => {
setShowWordSeedLookup(false);
startNewGame(wordSeedValue, null);
}}
style={{
background: "rgba(34,197,94,0.15)",
border: "1px solid rgba(34,197,94,0.3)",
borderRadius: 6, padding: "6px 12px",
color: "#22c55e", fontSize: 12,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
}}
>
Play
</button>
)}
{wordSeedResult && (
<div style={{
width: "100%", color: wordSeedValue ? "#22c55e" : "#f97316",
fontSize: 12, fontFamily: "'DM Mono', monospace", marginTop: 2,
}}>
{wordSeedResult}
</div>
)}
<div style={{
width: "100%", color: "#666",
fontSize: 11, fontFamily: "'DM Mono', monospace",
}}>
lookup uses the full secret-word list (all difficulties)
</div>
</div>
)}
{/* Difficulty picker (shown before first game) */}
{needsDifficultyPick && !lobbyMode && (
<div style={{
textAlign: "center", padding: "20px 0", marginBottom: 16,
animation: "slideIn 0.3s ease",
}}>
<div style={{
color: "#888", fontSize: 13, fontFamily: "'DM Mono', monospace",
marginBottom: 12,
}}>
choose difficulty
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
{[
{ label: "Easy", value: "easy" },
{ label: "Medium", value: "medium" },
{ label: "Hard", value: "hard" },
{ label: "Any", value: null },
].map(d => (
<button
key={d.label}
onClick={() => { setDifficulty(d.value); startNewGame(undefined, d.value, startHints); }}
style={{
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8,
padding: "12px 24px", color: "#e8e6e1", fontSize: 15,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
letterSpacing: "0.05em", transition: "all 0.2s",
}}
>
{d.label}
</button>
))}
</div>
<div style={{
display: "flex", alignItems: "center", justifyContent: "center",
gap: 10, marginTop: 16,
}}>
<label style={{
color: "#666", fontSize: 12, fontFamily: "'DM Mono', monospace",
}}>
start with word hints:
</label>
<input
type="number" min="0" max="11" value={startHints}
onChange={e => setStartHints(Math.max(0, Math.min(11, parseInt(e.target.value) || 0)))}
style={{
width: 48, background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.1)", borderRadius: 6,
padding: "6px 8px", color: "#e8e6e1", fontSize: 14,
fontFamily: "'DM Mono', monospace", outline: "none",
textAlign: "center",
}}
/>
</div>
<div style={{ marginTop: 16, borderTop: "1px solid rgba(255,255,255,0.08)", paddingTop: 16 }}>
<button onClick={() => setLobbyMode(true)} style={{
background: "rgba(59,130,246,0.1)", border: "1px solid rgba(59,130,246,0.3)",
borderRadius: 8, padding: "12px 24px", color: "#3b82f6", fontSize: 15,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
letterSpacing: "0.05em", transition: "all 0.2s",
}}>
Multiplayer
</button>
</div>
<div style={{ marginTop: 10 }}>
<button
onClick={() => {
setShowWordSeedLookup(v => !v);
setWordSeedInput("");
setWordSeedResult("");
setWordSeedValue("");
}}
style={{
background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.12)",
borderRadius: 8, padding: "8px 16px", color: "#aaa", fontSize: 12,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
letterSpacing: "0.03em", transition: "all 0.2s",
}}
>
Word to seed
</button>
</div>
</div>
)}
{/* Input */}
{!gameOver && !needsDifficultyPick && !lobbyMode && (
<div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
<input
ref={inputRef}
type="text"
value={input}
onChange={e => { setInput(e.target.value); setError(""); setShowGiveUp(false); setShowHintMenu(false); setShowMainMenuConfirm(false); }}
onKeyDown={e => e.key === "Enter" && !loading && handleGuess()}
placeholder="type a word..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
disabled={loading || starting}
style={{
flex: 1, background: "rgba(255,255,255,0.06)",
border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8,
padding: "12px 16px", color: "#e8e6e1", fontSize: 16,
fontFamily: "'DM Mono', monospace", outline: "none",
transition: "border-color 0.2s",
}}
onFocus={e => e.target.style.borderColor = "rgba(255,255,255,0.35)"}
onBlur={e => e.target.style.borderColor = "rgba(255,255,255,0.15)"}
/>
<button
onMouseDown={e => e.preventDefault()}
onClick={handleGuess}
disabled={loading || !input.trim() || starting}
style={{
background: loading ? "rgba(255,255,255,0.06)" : "rgba(255,255,255,0.12)",
border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8,
padding: "12px 20px",
color: loading ? "#555" : "#e8e6e1", fontSize: 14,
fontFamily: "'DM Mono', monospace", cursor: loading ? "wait" : "pointer",
transition: "all 0.2s", textTransform: "uppercase", letterSpacing: "0.05em",
}}
>
{loading ? "\u00B7\u00B7\u00B7" : "Guess"}
</button>
</div>
)}
{error && (
<div style={{
color: "#f97316", fontSize: 13,
fontFamily: "'DM Mono', monospace", marginBottom: 8, paddingLeft: 4
}}>{error}</div>
)}
{lastResult && !loading && !gameOver && (
<div style={{
display: "flex", alignItems: "center", gap: 12,
padding: "10px 16px", marginBottom: 8, borderRadius: 8,
background: lastResult.isDuplicate ? "rgba(255,255,255,0.04)" : "rgba(255,255,255,0.06)",
border: `1px solid ${scoreColor(lastResult.score)}33`,
animation: "slideIn 0.3s ease",
}}>
<span style={{
color: scoreColor(lastResult.score), fontSize: 22, fontWeight: 700,
fontFamily: "'DM Mono', monospace", minWidth: 52,
}}>
{lastResult.score.toFixed(2)}
</span>
<span style={{
color: "#e8e6e1", fontSize: 15,
fontFamily: "'DM Mono', monospace", flex: 1,
}}>
{lastResult.word}
</span>
<span style={{
color: "#777", fontSize: 12,
fontFamily: "'DM Mono', monospace",
}}>
rank #{lastResult.rank}{lastResult.isDuplicate ? " (already guessed)" : ""}
</span>
</div>
)}
{loading && (
<div style={{
color: "#666", fontSize: 13, fontFamily: "'DM Mono', monospace",
animation: "pulse 1.5s infinite", marginBottom: 8, paddingLeft: 4,
}}>scoring...</div>
)}
{/* Revealed hints display */}
{(posRevealed || concretenessLabel || vadLabel || sensorimotorLabel || glasgowLabel || definitionText || conceptnetHints.length > 0 || categoryHints.length > 0) && !gameOver && !needsDifficultyPick && !lobbyMode && (
<div style={{ marginBottom: 8, paddingLeft: 4 }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{posRevealed && (
<span style={{
background: "rgba(139,92,246,0.15)", border: "1px solid rgba(139,92,246,0.3)",
borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#a78bfa",
fontFamily: "'DM Mono', monospace",
}}>
Part of Speech: {posRevealed}
</span>
)}
{concretenessLabel && (
<span style={{
background: "rgba(139,92,246,0.15)", border: "1px solid rgba(139,92,246,0.3)",
borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#a78bfa",
fontFamily: "'DM Mono', monospace",
}}>
Concreteness: {concretenessLabel}
</span>
)}
{vadLabel && (
<span style={{
background: "rgba(16,185,129,0.12)", border: "1px solid rgba(16,185,129,0.3)",
borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#34d399",
fontFamily: "'DM Mono', monospace",
}}>
Emotional Tone: {vadLabel}
</span>
)}
{sensorimotorLabel && (
<span style={{
background: "rgba(16,185,129,0.12)", border: "1px solid rgba(16,185,129,0.3)",
borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#34d399",
fontFamily: "'DM Mono', monospace",
}}>
Senses & Action: {sensorimotorLabel}
</span>
)}
{glasgowLabel && (
<span style={{
background: "rgba(16,185,129,0.12)", border: "1px solid rgba(16,185,129,0.3)",
borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#34d399",
fontFamily: "'DM Mono', monospace",
}}>
Familiarity & Imagery: {glasgowLabel}
</span>
)}
{conceptnetHints.map((cn, i) => (
<span key={`cn-${i}`} style={{
background: "rgba(234,179,8,0.1)", border: "1px solid rgba(234,179,8,0.25)",
borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#eab308",
fontFamily: "'DM Mono', monospace",
}}>
Semantic Clue: {cn.relation} ({cn.values.join(", ")})
</span>
))}
{categoryHints.map((cat, i) => (
<span key={i} style={{
background: "rgba(234,179,8,0.1)", border: "1px solid rgba(234,179,8,0.25)",
borderRadius: 12, padding: "3px 10px", fontSize: 12, color: "#eab308",
fontFamily: "'DM Mono', monospace",
}}>
Category: {cat}
</span>
))}
</div>
{definitionText && (
<div style={{
background: "rgba(59,130,246,0.08)", border: "1px solid rgba(59,130,246,0.2)",
borderRadius: 8, padding: "8px 12px", marginTop: 6,
fontSize: 13, color: "#93c5fd", fontFamily: "'DM Mono', monospace",
lineHeight: 1.5, fontStyle: "italic",
}}>
{definitionText}
</div>
)}
</div>
)}
{revealedHintLabels.length > 0 && !gameOver && !needsDifficultyPick && !lobbyMode && (
<div className="hint-chip-row">
{revealedHintLabels.map(label => (
<span key={label} className="hint-chip">{label}</span>
))}
</div>
)}
{/* Hint menu & Give up */}
{!gameOver && !needsDifficultyPick && !lobbyMode && (
<div style={{ marginBottom: 8, position: "relative" }}>
<div style={{ display: "flex", gap: 8 }}>
<button
onMouseDown={e => e.preventDefault()}
onClick={() => { setShowHintMenu(!showHintMenu); setShowGiveUp(false); }}
style={{
flex: 1, background: "transparent",
border: showHintMenu ? "1px solid rgba(234,179,8,0.4)" : "1px solid rgba(255,255,255,0.12)",
borderRadius: 8, padding: "12px",
color: showHintMenu ? "#eab308" : "#777", fontSize: 13,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
transition: "all 0.2s", letterSpacing: "0.05em",
}}
>
{showHintMenu ? "Hints \u25B4" : "Hints \u25BE"}
</button>
<button
onMouseDown={e => e.preventDefault()}
onClick={handleGiveUp}
style={{
flex: 1, background: "transparent",
border: showGiveUp ? "1px solid rgba(239,68,68,0.4)" : "1px solid rgba(255,255,255,0.12)",
borderRadius: 8, padding: "12px",
color: showGiveUp ? "#ef4444" : "#777", fontSize: 13,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
transition: "all 0.2s", letterSpacing: "0.05em",
}}
>
{showGiveUp ? "Confirm give up?" : "Give up"}
</button>
</div>
{showHintMenu && (
<div className="hint-panel" style={{
marginTop: 6, background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.12)", borderRadius: 8,
padding: 6, display: "flex", flexDirection: "column", gap: 4,
animation: "slideIn 0.2s ease",
}}>
<div className="hint-section-title">Core</div>
<div className="hint-grid">
{hintsUsed < MAX_HINTS && (
<button
onMouseDown={e => e.preventDefault()}
onClick={() => handleHint()}
style={{
background: "transparent",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 6, padding: "8px 12px", textAlign: "left",
color: "#999", fontSize: 13,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
}} className="hint-btn"
>
{`Similar Word (${MAX_HINTS - hintsUsed} left)`}
</button>
)}
{hasCategories && (
<button
onMouseDown={e => e.preventDefault()}
onClick={async () => {
try {
const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/category`, { method: "POST" });
if (res.status === 400) { setHasCategories(false); return; }
if (!res.ok) throw new Error();
const data = await res.json();
setCategoryHints(prev => [...prev, data.category]);
setTotalHints(data.total_hints);
if (!data.has_more) setHasCategories(false);
} catch { setError("Request failed."); }
}}
style={{
background: "transparent",
border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6,
padding: "10px 12px", textAlign: "left",
color: "#999", fontSize: 13,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
}} className="hint-btn"
>
{categoryHints.length > 0
? `Category: ${categoryHints.join(" \u2192 ")} \u2192 ???`
: "Category"}
</button>
)}
{hasDefinition && !definitionDone && (
<button
onMouseDown={e => e.preventDefault()}
onClick={async () => {
try {
const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/definition`, { method: "POST" });
if (res.status === 400) { setDefinitionDone(true); return; }
if (!res.ok) throw new Error();
const data = await res.json();
setDefinitionText(data.definition);
setDefinitionDone(data.done);
setTotalHints(data.total_hints);
} catch { setError("Request failed."); }
}}
style={{
background: "transparent",
border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6,
padding: "10px 12px", textAlign: "left",
color: "#999", fontSize: 13,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
}} className="hint-btn"
>
{definitionText ? "Reveal More Definition" : "Definition"}
</button>
)}
{!posRevealed && (
<button
onMouseDown={e => e.preventDefault()}
onClick={async () => {
try {
const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/pos`, { method: "POST" });
if (!res.ok) throw new Error();
const data = await res.json();
setPosRevealed(data.pos);
setTotalHints(data.total_hints);
} catch { setError("Request failed."); }
}}
style={{
background: "transparent",
border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6,
padding: "10px 12px", textAlign: "left",
color: "#999", fontSize: 13,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
}} className="hint-btn"
>
Part of Speech
</button>
)}
</div>
<div className="hint-section-title">Advanced</div>
<div className="hint-grid">
{hasConcreteness && !concretenessLabel && (
<button
onMouseDown={e => e.preventDefault()}
onClick={async () => {
try {
const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/concreteness`, { method: "POST" });
if (res.status === 400) { setHasConcreteness(false); return; }
if (!res.ok) throw new Error();
const data = await res.json();
setConcretenessLabel(data.concreteness);
setTotalHints(data.total_hints);
} catch { setError("Request failed."); }
}}
style={{
background: "transparent",
border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6,
padding: "10px 12px", textAlign: "left",
color: "#999", fontSize: 13,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
}} className="hint-btn"
>
Concreteness
</button>
)}
{hasVad && !vadLabel && (
<button
onMouseDown={e => e.preventDefault()}
onClick={async () => {
try {
const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/vad`, { method: "POST" });
if (res.status === 400) { setHasVad(false); return; }
if (!res.ok) throw new Error();
const data = await res.json();
setVadLabel(data.label);
setTotalHints(data.total_hints);
} catch { setError("Request failed."); }
}}
style={{
background: "transparent",
border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6,
padding: "10px 12px", textAlign: "left",
color: "#999", fontSize: 13,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
}} className="hint-btn"
>
Emotional Tone
</button>
)}
{hasSensorimotor && !sensorimotorLabel && (
<button
onMouseDown={e => e.preventDefault()}
onClick={async () => {
try {
const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/sensorimotor`, { method: "POST" });
if (res.status === 400) { setHasSensorimotor(false); return; }
if (!res.ok) throw new Error();
const data = await res.json();
setSensorimotorLabel(data.label);
setTotalHints(data.total_hints);
} catch { setError("Request failed."); }
}}
style={{
background: "transparent",
border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6,
padding: "10px 12px", textAlign: "left",
color: "#999", fontSize: 13,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
}} className="hint-btn"
>
Senses & Action
</button>
)}
{hasGlasgow && !glasgowLabel && (
<button
onMouseDown={e => e.preventDefault()}
onClick={async () => {
try {
const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/glasgow`, { method: "POST" });
if (res.status === 400) { setHasGlasgow(false); return; }
if (!res.ok) throw new Error();
const data = await res.json();
setGlasgowLabel(data.label);
setTotalHints(data.total_hints);
} catch { setError("Request failed."); }
}}
style={{
background: "transparent",
border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6,
padding: "10px 12px", textAlign: "left",
color: "#999", fontSize: 13,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
}} className="hint-btn"
>
Familiarity & Imagery
</button>
)}
{hasConceptnet && (
<button
onMouseDown={e => e.preventDefault()}
onClick={async () => {
try {
const res = await fetch(`${API_BASE}/api/game/${gameId}/hint/conceptnet`, { method: "POST" });
if (res.status === 400) { setHasConceptnet(false); return; }
if (!res.ok) throw new Error();
const data = await res.json();
setConceptnetHints(prev => [...prev, { relation: data.relation, values: data.values }]);
setTotalHints(data.total_hints);
if (!data.has_more) setHasConceptnet(false);
} catch { setError("Request failed."); }
}}
style={{
background: "transparent",
border: "1px solid rgba(255,255,255,0.08)", borderRadius: 6,
padding: "10px 12px", textAlign: "left",
color: "#999", fontSize: 13,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
}} className="hint-btn"
>
{conceptnetHints.length > 0
? `Semantic Clues (${conceptnetHints.length} shown)`
: "Semantic Clues"}
</button>
)}
</div>
</div>
)}
</div>
)}
</div>
{/* Solved / Gave Up */}
{gameOver && !lobbyMode && (
<div style={{
width: "100%", maxWidth: 600, padding: "0 24px", marginBottom: 16,
animation: "celebrate 0.5s ease",
}}>
<div style={{
background: solved ? "rgba(34,197,94,0.1)" : "rgba(239,68,68,0.1)",
border: `1px solid ${solved ? "rgba(34,197,94,0.3)" : "rgba(239,68,68,0.3)"}`,
borderRadius: 12, padding: "20px 24px", textAlign: "center",
}}>
<div style={{
fontFamily: "'Playfair Display', serif", fontSize: 28, fontWeight: 900,
color: solved ? "#22c55e" : "#ef4444", marginBottom: 4,
}}>
{solved ? "Solved!" : "The word was:"}
</div>
<div style={{
fontFamily: "'DM Mono', monospace", fontSize: 20,
color: "#e8e6e1", letterSpacing: "0.15em",
textTransform: "uppercase", marginTop: 4,
}}>
{secretWord}
</div>
{solved && (
<div style={{ color: "#888", fontSize: 13, marginTop: 8, fontFamily: "'DM Mono', monospace" }}>
in {guessCount} guess{guessCount !== 1 ? "es" : ""}{totalHints > 0 ? ` (${totalHints} hint${totalHints !== 1 ? "s" : ""})` : ""}
</div>
)}
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap", marginTop: 16 }}>
<button
onClick={() => startNewGame()}
style={{
background: "rgba(255,255,255,0.1)",
border: "1px solid rgba(255,255,255,0.15)", borderRadius: 8,
padding: "10px 24px", color: "#e8e6e1", fontSize: 14,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
letterSpacing: "0.05em", textTransform: "uppercase",
}}
>
New Game
</button>
<button
onClick={() => {
const status = solved
? `Solved in ${guessCount} guess${guessCount !== 1 ? "es" : ""}`
: `Gave up after ${guessCount} guess${guessCount !== 1 ? "es" : ""}`;
const hintParts = [];
if (posRevealed) hintParts.push("pos");
if (concretenessLabel) hintParts.push("concreteness");
if (vadLabel) hintParts.push("vad");
if (sensorimotorLabel) hintParts.push("sensorimotor");
if (glasgowLabel) hintParts.push("glasgow");
if (definitionText) hintParts.push("definition");
if (categoryHints.length) hintParts.push(`${categoryHints.length} category`);
if (conceptnetHints.length) hintParts.push(`${conceptnetHints.length} semantic`);
if (hintsUsed) hintParts.push(`${hintsUsed} word`);
const hintLine = hintParts.length ? `\nHints: ${hintParts.join(", ")}` : "";
const url = `${window.location.origin}${window.location.pathname}?seed=${encodeURIComponent(seed)}`;
const text = `Semantick - ${status}${hintLine}\n${url}`;
navigator.clipboard.writeText(text).then(() => {
setShareCopied(true);
setTimeout(() => setShareCopied(false), 3000);
});
}}
style={{
background: shareCopied ? "rgba(34,197,94,0.15)" : "rgba(255,255,255,0.06)",
border: `1px solid ${shareCopied ? "rgba(34,197,94,0.3)" : "rgba(255,255,255,0.15)"}`, borderRadius: 8,
padding: "10px 24px", color: shareCopied ? "#22c55e" : "#aaa", fontSize: 14,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
letterSpacing: "0.05em", textTransform: "uppercase",
transition: "all 0.2s",
}}
>
{shareCopied ? "Copied!" : "Share result"}
</button>
</div>
{/* Difficulty selector */}
<div style={{ marginTop: 12 }}>
<div style={{ color: "#555", fontSize: 11, fontFamily: "'DM Mono', monospace", textAlign: "center", marginBottom: 6 }}>
next game difficulty
</div>
<div style={{ display: "flex", gap: 6, justifyContent: "center" }}>
{[
{ label: "Easy", value: "easy" },
{ label: "Medium", value: "medium" },
{ label: "Hard", value: "hard" },
{ label: "Any", value: null },
].map(d => (
<button
key={d.label}
onClick={() => setDifficulty(d.value)}
style={{
background: difficulty === d.value ? "rgba(255,255,255,0.12)" : "transparent",
border: difficulty === d.value ? "1px solid rgba(255,255,255,0.25)" : "1px solid rgba(255,255,255,0.1)",
borderRadius: 6, padding: "5px 12px",
color: difficulty === d.value ? "#e8e6e1" : "#666", fontSize: 12,
fontFamily: "'DM Mono', monospace", cursor: "pointer",
transition: "all 0.2s",
}}
>
{d.label}
</button>
))}
</div>
</div>
<div style={{ color: "#555", fontSize: 11, marginTop: 8, fontFamily: "'DM Mono', monospace" }}>
or press Enter for a new game
</div>
</div>
</div>
)}
{/* Guesses list */}
{guesses.length > 0 && !lobbyMode && (
<div ref={listRef} style={{
width: "100%", maxWidth: 600, padding: "0 24px",
}}>
<div className="guess-grid" style={{
display: "grid",
gridTemplateColumns: "36px 1fr 56px 56px 1fr",
gap: 10, padding: "8px 16px",
color: "#555", fontSize: 11,
fontFamily: "'DM Mono', monospace",
textTransform: "uppercase", letterSpacing: "0.1em",
}}>
<span>#</span>
<span>Word</span>
<span style={{ textAlign: "right" }}>Score</span>
<span className="rank-col" style={{ textAlign: "right" }}>Rank</span>
<span></span>
</div>
{sortedGuesses.map((g, i) => (
<GuessRow
key={`${g.word}-${i}`}
guess={g}
index={i + 1}
isNew={g.word === latestGuessWord}
isBest={i === 0 && guesses.length > 1}
/>
))}
</div>
)}
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<SemanticGuess />);
</script>
</body>
</html>