diff --git a/web/README.md b/web/README.md
index dd8a1f0..8f16b2d 100644
--- a/web/README.md
+++ b/web/README.md
@@ -1,2 +1,5 @@
# loldle
Just a rewritten LoLdle
+
+## Credits
+- [champions.json](assets/champions.json) is from [Kerrders's gist](https://gist.github.com/Kerrders/0067d88dfd982c272e20dcb496f4dbc7). Checkout how he export it in [this repo](https://github.com/Kerrders/LoLdleData).
diff --git a/web/css/styles.css b/web/css/styles.css
new file mode 100644
index 0000000..a68e0dc
--- /dev/null
+++ b/web/css/styles.css
@@ -0,0 +1,403 @@
+/* LoLdle Classic — Dark theme, responsive grid */
+
+:root {
+ --color-bg: #1a1a2e;
+ --color-surface: #16213e;
+ --color-surface-hover: #1a2745;
+ --color-text: #e0e0e0;
+ --color-text-muted: #8a8a9a;
+ --color-correct: #4ecb71;
+ --color-partial: #f4b35e;
+ --color-wrong: #d85b5b;
+ --color-header: #2a2a4a;
+ --color-input-bg: #0f3460;
+ --color-input-border: #533483;
+ --color-accent: #533483;
+ --radius: 8px;
+ --cell-size: 90px;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
+ background: var(--color-bg);
+ color: var(--color-text);
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+/* Header */
+.header {
+ text-align: center;
+ padding: 24px 16px 8px;
+ width: 100%;
+ max-width: 900px;
+}
+
+.header h1 {
+ font-size: 2rem;
+ font-weight: 700;
+ letter-spacing: 2px;
+ color: var(--color-text);
+}
+
+.header h1 span {
+ color: var(--color-accent);
+}
+
+/* Mode toggle */
+.mode-toggle {
+ display: flex;
+ justify-content: center;
+ gap: 4px;
+ margin: 12px 0 16px;
+ background: var(--color-surface);
+ border-radius: var(--radius);
+ padding: 4px;
+ width: fit-content;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.mode-btn {
+ padding: 8px 20px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ font-size: 0.9rem;
+ font-weight: 500;
+ transition: all 0.2s;
+}
+
+.mode-btn.active {
+ background: var(--color-accent);
+ color: var(--color-text);
+}
+
+.mode-btn:hover:not(.active) {
+ color: var(--color-text);
+ background: var(--color-surface-hover);
+}
+
+/* Search */
+.search-container {
+ position: relative;
+ width: 100%;
+ max-width: 400px;
+ margin: 0 auto 20px;
+ padding: 0 16px;
+}
+
+#search-input {
+ width: 100%;
+ padding: 12px 16px;
+ border: 2px solid var(--color-input-border);
+ border-radius: var(--radius);
+ background: var(--color-input-bg);
+ color: var(--color-text);
+ font-size: 1rem;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+#search-input:focus {
+ border-color: var(--color-accent);
+}
+
+#search-input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+#search-input::placeholder {
+ color: var(--color-text-muted);
+}
+
+#search-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 16px;
+ right: 16px;
+ background: var(--color-surface);
+ border: 1px solid var(--color-input-border);
+ border-radius: 0 0 var(--radius) var(--radius);
+ max-height: 320px;
+ overflow-y: auto;
+ z-index: 100;
+ display: none;
+}
+
+#search-dropdown.visible {
+ display: block;
+}
+
+.dropdown-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 12px;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.dropdown-item:hover,
+.dropdown-item.active {
+ background: var(--color-surface-hover);
+}
+
+.dropdown-item img {
+ width: 36px;
+ height: 36px;
+ border-radius: 4px;
+ object-fit: cover;
+}
+
+.dropdown-item span {
+ font-size: 0.95rem;
+}
+
+/* Guess counter */
+.guess-counter {
+ text-align: center;
+ color: var(--color-text-muted);
+ font-size: 0.9rem;
+ margin-bottom: 12px;
+}
+
+/* Grid */
+.grid-container {
+ width: 100%;
+ max-width: 900px;
+ padding: 0 16px;
+ overflow-x: auto;
+}
+
+.guess-row {
+ display: grid;
+ grid-template-columns: 130px repeat(7, var(--cell-size));
+ gap: 4px;
+ margin-bottom: 4px;
+ min-width: fit-content;
+}
+
+.guess-cell {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 8px 4px;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-weight: 500;
+ min-height: 56px;
+ word-break: break-word;
+ animation: cellReveal 0.3s ease forwards;
+ opacity: 0;
+}
+
+/* Header row */
+.header-row {
+ margin-bottom: 8px;
+}
+
+.header-cell {
+ background: var(--color-header);
+ color: var(--color-text-muted);
+ font-weight: 600;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ opacity: 1;
+ animation: none;
+}
+
+/* Champion cell */
+.champion-cell {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ justify-content: flex-start;
+ padding-left: 8px;
+ background: var(--color-surface);
+}
+
+.champion-cell img {
+ width: 40px;
+ height: 40px;
+ border-radius: 4px;
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.champion-cell span {
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+/* Result colors */
+.cell-correct {
+ background: var(--color-correct);
+ color: #1a1a2e;
+}
+
+.cell-partial {
+ background: var(--color-partial);
+ color: #1a1a2e;
+}
+
+.cell-wrong {
+ background: var(--color-wrong);
+ color: #fff;
+}
+
+/* Direction arrow */
+.direction-arrow {
+ font-weight: 700;
+ font-size: 1rem;
+}
+
+/* Cell animation */
+@keyframes cellReveal {
+ from {
+ opacity: 0;
+ transform: scale(0.8);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+/* Game over */
+.game-over {
+ text-align: center;
+ padding: 24px 16px;
+ margin: 16px auto;
+ max-width: 400px;
+}
+
+.game-over h2 {
+ font-size: 1.5rem;
+ margin-bottom: 8px;
+}
+
+.game-over.won h2 {
+ color: var(--color-correct);
+}
+
+.game-over.lost h2 {
+ color: var(--color-wrong);
+}
+
+.game-over p {
+ color: var(--color-text-muted);
+ margin-bottom: 12px;
+}
+
+.game-over strong {
+ color: var(--color-text);
+}
+
+.reveal-image {
+ width: 80px;
+ height: 80px;
+ border-radius: var(--radius);
+ object-fit: cover;
+ margin-top: 8px;
+}
+
+/* New game button (unlimited) */
+.new-game-btn {
+ display: inline-block;
+ margin-top: 16px;
+ padding: 10px 24px;
+ border: none;
+ border-radius: var(--radius);
+ background: var(--color-accent);
+ color: var(--color-text);
+ font-size: 0.95rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: opacity 0.2s;
+}
+
+.new-game-btn:hover {
+ opacity: 0.85;
+}
+
+/* Stats */
+.stats {
+ display: flex;
+ justify-content: center;
+ gap: 24px;
+ padding: 12px;
+ margin-bottom: 16px;
+}
+
+.stat-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+}
+
+.stat-value {
+ font-size: 1.3rem;
+ font-weight: 700;
+ color: var(--color-text);
+}
+
+.stat-label {
+ font-size: 0.7rem;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ :root {
+ --cell-size: 72px;
+ }
+
+ .header h1 {
+ font-size: 1.5rem;
+ }
+
+ .guess-row {
+ grid-template-columns: 100px repeat(7, var(--cell-size));
+ }
+
+ .guess-cell {
+ font-size: 0.7rem;
+ min-height: 48px;
+ padding: 6px 2px;
+ }
+
+ .champion-cell img {
+ width: 32px;
+ height: 32px;
+ }
+}
+
+@media (max-width: 480px) {
+ :root {
+ --cell-size: 60px;
+ }
+
+ .guess-row {
+ grid-template-columns: 80px repeat(7, var(--cell-size));
+ }
+
+ .champion-cell span {
+ font-size: 0.65rem;
+ }
+}
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..eb521fc
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ LoLdle Classic
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/js/app.js b/web/js/app.js
new file mode 100644
index 0000000..020625c
--- /dev/null
+++ b/web/js/app.js
@@ -0,0 +1,196 @@
+// Bootstrap: wire data, engine, classic mode, and UI together
+
+import { loadChampions, getAllChampions, getRandomChampion, getTodaySeed } from "./data-loader.js";
+import { createGame, submitGuess, getGuessedNames, clearUnlimitedState, saveUnlimitedStats, loadUnlimitedStats } from "./game-engine.js";
+import { compareChampions } from "./classic-mode.js";
+import {
+ initSearch,
+ updateSearchCallbacks,
+ renderGuessRow,
+ renderGridHeader,
+ renderGameOver,
+ updateGuessCounter,
+ setSearchEnabled,
+ renderStats,
+} from "./ui-renderer.js";
+
+const UNLIMITED_SEED_KEY = "loldle_unlimited_seed";
+
+let currentGame = null;
+let currentMode = "daily";
+
+async function init() {
+ try {
+ await loadChampions();
+ } catch (err) {
+ document.getElementById("grid").textContent = "Failed to load champion data. Please refresh.";
+ return;
+ }
+
+ const champions = getAllChampions();
+ if (!champions.length) {
+ document.getElementById("grid").textContent = "No champion data available.";
+ return;
+ }
+
+ // Initialize search once with champion list
+ const searchContainer = document.querySelector(".search-container");
+ initSearch(searchContainer, champions);
+
+ // Mode toggle buttons
+ document.getElementById("mode-daily").addEventListener("click", () => switchMode("daily"));
+ document.getElementById("mode-unlimited").addEventListener("click", () => switchMode("unlimited"));
+
+ // Start with daily mode
+ startGame("daily");
+}
+
+function switchMode(mode) {
+ currentMode = mode;
+
+ // Update toggle UI
+ document.querySelectorAll(".mode-btn").forEach((btn) => btn.classList.remove("active"));
+ document.getElementById(`mode-${mode}`).classList.add("active");
+
+ startGame(mode);
+}
+
+function startGame(mode) {
+ const gridContainer = document.getElementById("grid");
+ const gameOverContainer = document.getElementById("game-over");
+ const statsContainer = document.getElementById("stats");
+ const searchContainer = document.querySelector(".search-container");
+ const guessCounter = document.getElementById("guess-counter");
+
+ // Clear previous state
+ gridContainer.innerHTML = "";
+ gameOverContainer.innerHTML = "";
+
+ // Show/hide stats
+ if (mode === "unlimited") {
+ renderStats(statsContainer, loadUnlimitedStats());
+ } else {
+ statsContainer.innerHTML = "";
+ }
+
+ // Pick target champion
+ let target;
+ let seed = "";
+ if (mode === "daily") {
+ seed = getTodaySeed();
+ target = getRandomChampion(seed);
+ } else {
+ // Unlimited: reuse persisted seed if game is in progress, else create new
+ seed = getOrCreateUnlimitedSeed();
+ target = getRandomChampion(seed);
+ }
+
+ // Create game
+ const maxGuesses = mode === "daily" ? 6 : 0;
+ currentGame = createGame({
+ target,
+ compareFn: compareChampions,
+ maxGuesses,
+ mode,
+ seed,
+ });
+
+ // Render grid header
+ renderGridHeader(gridContainer);
+
+ // Restore previous guesses (from localStorage)
+ if (currentGame.guesses.length > 0) {
+ currentGame.results.forEach((result, i) => {
+ renderGuessRow(gridContainer, currentGame.guesses[i], result);
+ });
+ }
+
+ // Update counter
+ updateGuessCounter(guessCounter, currentGame.guesses.length, currentGame.maxGuesses);
+
+ // Handle already-finished game (restored from storage)
+ if (currentGame.isOver) {
+ setSearchEnabled(searchContainer, false);
+ renderGameOver(gameOverContainer, currentGame.isWon, currentGame.target, currentGame.guesses.length);
+ if (mode === "unlimited") {
+ addNewGameButton(gameOverContainer);
+ }
+ return;
+ }
+
+ // Enable search and update callbacks for this game instance
+ setSearchEnabled(searchContainer, true);
+ const input = searchContainer.querySelector("#search-input");
+ input.placeholder = "Type a champion name...";
+ input.value = "";
+ input.focus();
+
+ updateSearchCallbacks(
+ (champion) => handleGuess(champion, gridContainer, gameOverContainer, searchContainer, guessCounter, statsContainer),
+ () => getGuessedNames(currentGame),
+ );
+}
+
+function handleGuess(champion, gridContainer, gameOverContainer, searchContainer, guessCounter, statsContainer) {
+ const result = submitGuess(currentGame, champion);
+ if (!result) return;
+
+ renderGuessRow(gridContainer, champion, result);
+ updateGuessCounter(guessCounter, currentGame.guesses.length, currentGame.maxGuesses);
+
+ if (currentGame.isOver) {
+ setSearchEnabled(searchContainer, false);
+ renderGameOver(gameOverContainer, currentGame.isWon, currentGame.target, currentGame.guesses.length);
+
+ if (currentGame.mode === "unlimited") {
+ saveUnlimitedStats(currentGame);
+ renderStats(statsContainer, loadUnlimitedStats());
+ addNewGameButton(gameOverContainer);
+ }
+ } else {
+ // Refocus input for next guess
+ searchContainer.querySelector("#search-input").focus();
+ }
+}
+
+/** Get or create a persistent seed for unlimited mode */
+function getOrCreateUnlimitedSeed() {
+ try {
+ const saved = localStorage.getItem(UNLIMITED_SEED_KEY);
+ if (saved) return saved;
+ } catch {
+ // Ignore
+ }
+ return createNewUnlimitedSeed();
+}
+
+/** Create and persist a new unlimited seed */
+function createNewUnlimitedSeed() {
+ const seed = `unlimited_${Date.now()}_${Math.random()}`;
+ try {
+ localStorage.setItem(UNLIMITED_SEED_KEY, seed);
+ } catch {
+ // Ignore
+ }
+ return seed;
+}
+
+function addNewGameButton(container) {
+ const btn = document.createElement("button");
+ btn.className = "new-game-btn";
+ btn.textContent = "New Game";
+ btn.addEventListener("click", () => {
+ clearUnlimitedState();
+ // Clear the persisted seed so a new one is generated
+ try {
+ localStorage.removeItem(UNLIMITED_SEED_KEY);
+ } catch {
+ // Ignore
+ }
+ startGame("unlimited");
+ });
+ container.appendChild(btn);
+}
+
+// Start the app
+init();
diff --git a/web/js/classic-mode.js b/web/js/classic-mode.js
new file mode 100644
index 0000000..b13204a
--- /dev/null
+++ b/web/js/classic-mode.js
@@ -0,0 +1,134 @@
+// Classic mode: compare two champions across 7 attributes
+
+/** Attributes displayed in order */
+export const CLASSIC_ATTRIBUTES = [
+ { key: "gender", label: "Gender", type: "exact" },
+ { key: "genre", label: "Genre", type: "multi" },
+ { key: "attackType", label: "Range", type: "exact" },
+ { key: "resource", label: "Resource", type: "exact" },
+ { key: "region", label: "Region", type: "exact" },
+ { key: "lane", label: "Lane", type: "multi" },
+ { key: "releaseDate", label: "Year", type: "year" },
+];
+
+/**
+ * Compare guess champion against target champion
+ * @returns {Array} Array of { attribute, label, guessValue, targetValue, result, direction? }
+ */
+export function compareChampions(guess, target) {
+ return CLASSIC_ATTRIBUTES.map((attr) => {
+ const guessVal = guess[attr.key] || "";
+ const targetVal = target[attr.key] || "";
+
+ switch (attr.type) {
+ case "exact":
+ return {
+ ...attr,
+ guessValue: formatValue(attr.key, guessVal),
+ targetValue: formatValue(attr.key, targetVal),
+ result: guessVal.toLowerCase() === targetVal.toLowerCase() ? "correct" : "wrong",
+ };
+
+ case "multi":
+ return {
+ ...attr,
+ guessValue: formatValue(attr.key, guessVal),
+ targetValue: formatValue(attr.key, targetVal),
+ result: compareMultiValue(guessVal, targetVal),
+ };
+
+ case "year":
+ return {
+ ...attr,
+ guessValue: guessVal || "?",
+ targetValue: targetVal || "?",
+ ...compareYear(guessVal, targetVal),
+ };
+
+ default:
+ return { ...attr, guessValue: guessVal, targetValue: targetVal, result: "wrong" };
+ }
+ });
+}
+
+/** Compare comma-separated multi-value fields */
+function compareMultiValue(guessStr, targetStr) {
+ const guessSet = parseSet(guessStr);
+ const targetSet = parseSet(targetStr);
+
+ if (guessSet.size === 0 && targetSet.size === 0) return "correct";
+ if (guessSet.size === 0 || targetSet.size === 0) return "wrong";
+
+ // Check if sets are equal
+ if (setsEqual(guessSet, targetSet)) return "correct";
+
+ // Check for any overlap
+ for (const val of guessSet) {
+ if (targetSet.has(val)) return "partial";
+ }
+
+ return "wrong";
+}
+
+/** Compare release years with direction */
+function compareYear(guessYear, targetYear) {
+ const g = Number(guessYear);
+ const t = Number(targetYear);
+
+ if (!g || !t) return { result: "wrong" };
+ if (g === t) return { result: "correct" };
+
+ return {
+ result: "wrong",
+ direction: g < t ? "up" : "down", // up = guess is too low
+ };
+}
+
+/** Parse comma-separated string into lowercase Set */
+function parseSet(str) {
+ if (!str) return new Set();
+ return new Set(
+ str
+ .split(",")
+ .map((s) => s.trim().toLowerCase())
+ .filter(Boolean),
+ );
+}
+
+/** Check if two Sets are equal */
+function setsEqual(a, b) {
+ if (a.size !== b.size) return false;
+ for (const val of a) {
+ if (!b.has(val)) return false;
+ }
+ return true;
+}
+
+/** Format display values for readability */
+function formatValue(key, value) {
+ if (!value) return "—";
+
+ switch (key) {
+ case "gender":
+ return capitalize(value);
+ case "attackType":
+ return value === "close" ? "Melee" : "Ranged";
+ case "region":
+ return value
+ .split("-")
+ .map(capitalize)
+ .join(" ");
+ case "genre":
+ case "lane":
+ return value
+ .split(",")
+ .map((s) => capitalize(s.trim()))
+ .join(", ");
+ default:
+ return value;
+ }
+}
+
+function capitalize(str) {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+}
diff --git a/web/js/data-loader.js b/web/js/data-loader.js
new file mode 100644
index 0000000..d038175
--- /dev/null
+++ b/web/js/data-loader.js
@@ -0,0 +1,52 @@
+// Data layer: load, search, and select champions from champions.json
+
+let champions = [];
+
+/** Fetch and parse champions data */
+export async function loadChampions() {
+ const response = await fetch("assets/champions.json");
+ if (!response.ok) {
+ throw new Error(`Failed to load champions: ${response.status}`);
+ }
+ champions = await response.json();
+ return champions;
+}
+
+/** Get all loaded champions */
+export function getAllChampions() {
+ return champions;
+}
+
+/** Case-insensitive lookup by name */
+export function getChampionByName(name) {
+ const lower = name.toLowerCase();
+ return champions.find((c) => c.name.toLowerCase() === lower) || null;
+}
+
+
+/** Seeded random champion selection (deterministic for same seed string) */
+export function getRandomChampion(seed) {
+ if (!champions.length) return null;
+ const hash = hashString(seed);
+ const index = hash % champions.length;
+ return champions[index];
+}
+
+/** Simple string hash (djb2) */
+function hashString(str) {
+ let hash = 5381;
+ for (let i = 0; i < str.length; i++) {
+ hash = (hash * 33) ^ str.charCodeAt(i);
+ }
+ return hash >>> 0;
+}
+
+/** Get today's date string for daily seed */
+export function getTodaySeed() {
+ return new Date().toISOString().slice(0, 10);
+}
+
+/** Get champion image URL from Data Dragon CDN */
+export function getChampionImageUrl(championId) {
+ return `https://ddragon.leagueoflegends.com/cdn/img/champion/tiles/${championId}_0.jpg`;
+}
diff --git a/web/js/game-engine.js b/web/js/game-engine.js
new file mode 100644
index 0000000..d94de73
--- /dev/null
+++ b/web/js/game-engine.js
@@ -0,0 +1,164 @@
+// Mode-agnostic game state machine with localStorage persistence
+
+const STORAGE_KEY_PREFIX = "loldle_";
+
+/**
+ * Create a new game instance
+ * @param {Object} config
+ * @param {Object} config.target - Target champion to guess
+ * @param {Function} config.compareFn - (guess, target) => comparison results
+ * @param {number} config.maxGuesses - Max allowed guesses (0 = unlimited)
+ * @param {string} config.mode - "daily" or "unlimited"
+ * @param {string} config.seed - Seed string for daily mode
+ */
+export function createGame(config) {
+ const { target, compareFn, maxGuesses = 6, mode = "daily", seed = "" } = config;
+
+ // Try to restore saved state for daily mode
+ const saved = loadState(mode, seed);
+ if (saved && saved.targetName === target.name) {
+ return {
+ target,
+ compareFn,
+ maxGuesses,
+ mode,
+ seed,
+ guesses: saved.guesses,
+ results: saved.results,
+ isOver: saved.isOver,
+ isWon: saved.isWon,
+ };
+ }
+
+ return {
+ target,
+ compareFn,
+ maxGuesses,
+ mode,
+ seed,
+ guesses: [],
+ results: [],
+ isOver: false,
+ isWon: false,
+ };
+}
+
+/**
+ * Submit a guess and return comparison result
+ * @returns {Object|null} Comparison result, or null if game is over
+ */
+export function submitGuess(game, champion) {
+ if (game.isOver) return null;
+
+ // Prevent duplicate guesses
+ if (game.guesses.some((g) => g.name === champion.name)) return null;
+
+ const result = game.compareFn(champion, game.target);
+ game.guesses.push(champion);
+ game.results.push(result);
+
+ // Check win
+ if (champion.name === game.target.name) {
+ game.isWon = true;
+ game.isOver = true;
+ }
+ // Check loss (only if maxGuesses > 0)
+ else if (game.maxGuesses > 0 && game.guesses.length >= game.maxGuesses) {
+ game.isOver = true;
+ }
+
+ saveState(game);
+ return result;
+}
+
+/** Get names of already-guessed champions */
+export function getGuessedNames(game) {
+ return game.guesses.map((g) => g.name);
+}
+
+/** Save game state to localStorage */
+function saveState(game) {
+ const key = getStorageKey(game.mode, game.seed);
+ const data = {
+ targetName: game.target.name,
+ guesses: game.guesses,
+ results: game.results,
+ isOver: game.isOver,
+ isWon: game.isWon,
+ timestamp: Date.now(),
+ };
+ try {
+ localStorage.setItem(key, JSON.stringify(data));
+ } catch {
+ // Storage full or unavailable — silently ignore
+ }
+}
+
+/** Load saved game state from localStorage */
+function loadState(mode, seed) {
+ const key = getStorageKey(mode, seed);
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return null;
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+/** Build storage key based on mode */
+function getStorageKey(mode, seed) {
+ if (mode === "daily") {
+ return `${STORAGE_KEY_PREFIX}daily_${seed}`;
+ }
+ return `${STORAGE_KEY_PREFIX}unlimited_current`;
+}
+
+/** Clear unlimited mode saved state (for new game) */
+export function clearUnlimitedState() {
+ try {
+ localStorage.removeItem(`${STORAGE_KEY_PREFIX}unlimited_current`);
+ } catch {
+ // Ignore
+ }
+}
+
+/** Save unlimited mode stats (wins, total games, guess distribution) */
+export function saveUnlimitedStats(game) {
+ if (game.mode !== "unlimited" || !game.isOver) return;
+
+ const key = `${STORAGE_KEY_PREFIX}unlimited_stats`;
+ let stats;
+ try {
+ stats = JSON.parse(localStorage.getItem(key)) || createEmptyStats();
+ } catch {
+ stats = createEmptyStats();
+ }
+
+ stats.gamesPlayed++;
+ if (game.isWon) {
+ stats.gamesWon++;
+ const guessCount = game.guesses.length;
+ stats.guessDistribution[guessCount] = (stats.guessDistribution[guessCount] || 0) + 1;
+ }
+ stats.lastPlayed = Date.now();
+
+ try {
+ localStorage.setItem(key, JSON.stringify(stats));
+ } catch {
+ // Ignore
+ }
+}
+
+/** Load unlimited stats */
+export function loadUnlimitedStats() {
+ try {
+ return JSON.parse(localStorage.getItem(`${STORAGE_KEY_PREFIX}unlimited_stats`)) || createEmptyStats();
+ } catch {
+ return createEmptyStats();
+ }
+}
+
+function createEmptyStats() {
+ return { gamesPlayed: 0, gamesWon: 0, guessDistribution: {}, lastPlayed: null };
+}
diff --git a/web/js/ui-renderer.js b/web/js/ui-renderer.js
new file mode 100644
index 0000000..85610ac
--- /dev/null
+++ b/web/js/ui-renderer.js
@@ -0,0 +1,272 @@
+// UI rendering: search autocomplete, guess grid, game state display
+
+import { getChampionImageUrl } from "./data-loader.js";
+import { CLASSIC_ATTRIBUTES } from "./classic-mode.js";
+
+// Mutable callback refs — updated on each startGame, listeners bound only once
+let _searchCallbacks = { onSelect: null, getExcluded: () => [] };
+let _searchInitialized = false;
+
+/** Update search callbacks (call on each new game) */
+export function updateSearchCallbacks(onSelect, getExcluded) {
+ _searchCallbacks.onSelect = onSelect;
+ _searchCallbacks.getExcluded = getExcluded;
+}
+
+/** Initialize search autocomplete (call once) */
+export function initSearch(container, champions) {
+ if (_searchInitialized) return;
+ _searchInitialized = true;
+
+ const input = container.querySelector("#search-input");
+ const dropdown = container.querySelector("#search-dropdown");
+
+ input.addEventListener("input", () => {
+ const query = input.value.trim();
+ if (!query) {
+ dropdown.innerHTML = "";
+ dropdown.classList.remove("visible");
+ return;
+ }
+
+ const excluded = _searchCallbacks.getExcluded();
+ const lower = query.toLowerCase();
+ const matches = champions
+ .filter(
+ (c) =>
+ c.name.toLowerCase().includes(lower) &&
+ !excluded.includes(c.name),
+ )
+ .sort((a, b) => {
+ const aStarts = a.name.toLowerCase().startsWith(lower) ? 0 : 1;
+ const bStarts = b.name.toLowerCase().startsWith(lower) ? 0 : 1;
+ return aStarts - bStarts || a.name.localeCompare(b.name);
+ })
+ .slice(0, 8);
+
+ renderDropdown(dropdown, matches, (champion) => {
+ input.value = "";
+ dropdown.innerHTML = "";
+ dropdown.classList.remove("visible");
+ if (_searchCallbacks.onSelect) _searchCallbacks.onSelect(champion);
+ });
+ });
+
+ // Close dropdown on outside click
+ document.addEventListener("click", (e) => {
+ if (!container.contains(e.target)) {
+ dropdown.innerHTML = "";
+ dropdown.classList.remove("visible");
+ }
+ });
+
+ // Keyboard navigation
+ input.addEventListener("keydown", (e) => {
+ const items = dropdown.querySelectorAll(".dropdown-item");
+ const active = dropdown.querySelector(".dropdown-item.active");
+
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ if (!active && items.length) {
+ items[0].classList.add("active");
+ } else if (active && active.nextElementSibling) {
+ active.classList.remove("active");
+ active.nextElementSibling.classList.add("active");
+ }
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ if (active && active.previousElementSibling) {
+ active.classList.remove("active");
+ active.previousElementSibling.classList.add("active");
+ }
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ const selected = dropdown.querySelector(".dropdown-item.active");
+ if (selected) selected.click();
+ }
+ });
+}
+
+/** Render dropdown options */
+function renderDropdown(dropdown, matches, onClick) {
+ dropdown.innerHTML = "";
+ if (!matches.length) {
+ dropdown.classList.remove("visible");
+ return;
+ }
+
+ matches.forEach((champion) => {
+ const item = document.createElement("div");
+ item.className = "dropdown-item";
+
+ const img = document.createElement("img");
+ img.src = getChampionImageUrl(champion.id);
+ img.alt = champion.name;
+ img.loading = "lazy";
+
+ const name = document.createElement("span");
+ name.textContent = champion.name;
+
+ item.append(img, name);
+ item.addEventListener("click", () => onClick(champion));
+ dropdown.appendChild(item);
+ });
+
+ dropdown.classList.add("visible");
+}
+
+/** Render a single guess row with comparison results */
+export function renderGuessRow(container, champion, results) {
+ const row = document.createElement("div");
+ row.className = "guess-row";
+
+ // Champion name + image cell
+ const nameCell = document.createElement("div");
+ nameCell.className = "guess-cell champion-cell";
+ const img = document.createElement("img");
+ img.src = getChampionImageUrl(champion.id);
+ img.alt = champion.name;
+ const nameSpan = document.createElement("span");
+ nameSpan.textContent = champion.name;
+ nameCell.append(img, nameSpan);
+ row.appendChild(nameCell);
+
+ // Attribute cells
+ results.forEach((r) => {
+ const cell = document.createElement("div");
+ cell.className = `guess-cell cell-${r.result}`;
+ cell.dataset.attribute = r.key;
+
+ const valueSpan = document.createElement("span");
+ valueSpan.textContent = r.guessValue;
+ cell.appendChild(valueSpan);
+
+ // Direction arrow for year
+ if (r.direction) {
+ const arrow = document.createElement("span");
+ arrow.className = "direction-arrow";
+ arrow.textContent = r.direction === "up" ? " ↑" : " ↓";
+ cell.appendChild(arrow);
+ }
+
+ row.appendChild(cell);
+ });
+
+ // Staggered animation
+ const cells = row.querySelectorAll(".guess-cell");
+ cells.forEach((cell, i) => {
+ cell.style.animationDelay = `${i * 0.08}s`;
+ });
+
+ container.appendChild(row);
+
+ // Scroll to latest guess
+ row.scrollIntoView({ behavior: "smooth", block: "nearest" });
+}
+
+/** Render column headers for the guess grid */
+export function renderGridHeader(container) {
+ const header = document.createElement("div");
+ header.className = "guess-row header-row";
+
+ // Champion column header
+ const champHeader = document.createElement("div");
+ champHeader.className = "guess-cell header-cell";
+ champHeader.textContent = "Champion";
+ header.appendChild(champHeader);
+
+ CLASSIC_ATTRIBUTES.forEach((attr) => {
+ const cell = document.createElement("div");
+ cell.className = "guess-cell header-cell";
+ cell.textContent = attr.label;
+ header.appendChild(cell);
+ });
+
+ container.appendChild(header);
+}
+
+/** Show game over message */
+export function renderGameOver(container, isWon, target, guessCount) {
+ const msg = document.createElement("div");
+ msg.className = `game-over ${isWon ? "won" : "lost"}`;
+
+ const h2 = document.createElement("h2");
+ const p = document.createElement("p");
+
+ if (isWon) {
+ h2.textContent = "You got it!";
+ const nameStrong = document.createElement("strong");
+ nameStrong.textContent = target.name;
+ const countStrong = document.createElement("strong");
+ countStrong.textContent = guessCount;
+ p.append("You found ", nameStrong, " in ", countStrong, ` guess${guessCount > 1 ? "es" : ""}!`);
+ } else {
+ h2.textContent = "Game Over";
+ const nameStrong = document.createElement("strong");
+ nameStrong.textContent = target.name;
+ p.append("The champion was ", nameStrong);
+ const img = document.createElement("img");
+ img.src = getChampionImageUrl(target.id);
+ img.alt = target.name;
+ img.className = "reveal-image";
+ msg.append(h2, p, img);
+ container.appendChild(msg);
+ return;
+ }
+
+ msg.append(h2, p);
+ container.appendChild(msg);
+}
+
+/** Update guess counter display */
+export function updateGuessCounter(element, current, max) {
+ if (max > 0) {
+ element.textContent = `${current} / ${max} guesses`;
+ } else {
+ element.textContent = `${current} guess${current !== 1 ? "es" : ""}`;
+ }
+}
+
+/** Show/hide search input */
+export function setSearchEnabled(container, enabled) {
+ const input = container.querySelector("#search-input");
+ if (input) {
+ input.disabled = !enabled;
+ if (!enabled) {
+ input.placeholder = "Game over";
+ container.querySelector("#search-dropdown").innerHTML = "";
+ container.querySelector("#search-dropdown").classList.remove("visible");
+ }
+ }
+}
+
+/** Render unlimited mode stats */
+export function renderStats(container, stats) {
+ container.innerHTML = "";
+ if (!stats || stats.gamesPlayed === 0) return;
+
+ const winRate = Math.round((stats.gamesWon / stats.gamesPlayed) * 100);
+ const statsDiv = document.createElement("div");
+ statsDiv.className = "stats";
+
+ const items = [
+ { value: stats.gamesPlayed, label: "Played" },
+ { value: `${winRate}%`, label: "Win Rate" },
+ { value: stats.gamesWon, label: "Won" },
+ ];
+
+ items.forEach(({ value, label }) => {
+ const item = document.createElement("div");
+ item.className = "stat-item";
+ const valSpan = document.createElement("span");
+ valSpan.className = "stat-value";
+ valSpan.textContent = value;
+ const labelSpan = document.createElement("span");
+ labelSpan.className = "stat-label";
+ labelSpan.textContent = label;
+ item.append(valSpan, labelSpan);
+ statsDiv.appendChild(item);
+ });
+
+ container.appendChild(statsDiv);
+}