const STORAGE_KEY = "levelup-typing-save-v1"; const WORD_BANKS = { easy: ["apple", "river", "light", "happy", "quick", "smile", "cloud", "dream", "friend", "learn", "typing", "music", "garden", "planet", "window", "ticket", "puzzle", "silver", "orange", "pepper"], normal: ["speed", "skill", "sprint", "rhythm", "tempo", "focus", "kinetic", "logic", "phrase", "arcade", "streak", "vector", "groove", "momentum", "signal", "catalyst", "syntax", "drift", "horizon", "quantum"], hard: ["architect", "backspace", "fragment", "notation", "velocity", "parallel", "hexadecimal", "interface", "algorithm", "synchronize", "compilation", "resolution", "parameter", "validation", "hyperlink", "generator", "trajectory", "oscillation", "framework", "benchmark"], expert: ["extraordinary", "counterclockwise", "asymptotically", "deconstruction", "characteristically", "electromagnetic", "microarchitecture", "misconfiguration", "representational", "interoperability", "cryptographically", "institutionalize", "uncharacteristic", "transcontinental", "hyperparameter", "instrumentation", "multiprocessing", "disproportionate", "comprehensibility", "photosynthesis"] }; const DIFFICULTY_CONFIG = { easy: { xp_mult: 0.9, round_words: 38 }, normal: { xp_mult: 1, round_words: 45 }, hard: { xp_mult: 1.25, round_words: 50 }, expert: { xp_mult: 1.5, round_words: 54 } }; const PERKS = [ { id: "combo-boost", name: "combo boost", unlock_level: 2, cost: 1, description: "earn 15% more xp while your best combo is 5 or higher." }, { id: "accuracy-bounty", name: "accuracy bounty", unlock_level: 4, cost: 2, description: "if you finish with 96%+ accuracy, gain +30 bonus xp." }, { id: "time-warp", name: "time warp", unlock_level: 6, cost: 2, description: "start rounds with +8 seconds for easier high-streak runs." }, { id: "focus-engine", name: "focus engine", unlock_level: 8, cost: 3, description: "at 98%+ accuracy, your wpm contributes an extra 25% xp." } ]; const DEFAULT_STATE = { profile_name: "rookie typer", difficulty: "normal", words: [], index: 0, typed_chars: 0, correct_words: 0, wrong_words: 0, combo: 0, best_combo: 0, timer: null, elapsed_seconds: 0, round_seconds: 60, running: false, xp: 0, level: 1, shards: 0, last_round_xp: 0, owned_perks: [], stats: { total_rounds: 0, best_wpm: 0, best_accuracy: 0, average_wpm: 0, average_accuracy: 0 }, recent_rounds: [] }; const state = { ...DEFAULT_STATE, stats: { ...DEFAULT_STATE.stats }, recent_rounds: [] }; const q = (id) => document.getElementById(id); const num = (v, d = 0) => (Number.isFinite(v) ? Number(v) : d); const snake_or_camel = (obj, snake, camel, fallback = undefined) => (obj?.[snake] ?? obj?.[camel] ?? fallback); const dom = { text_display: q("textDisplay"), type_input: q("typeInput"), new_round_btn: q("newRoundBtn"), settings_btn: q("settingsBtn"), close_settings_btn: q("closeSettingsBtn"), settings_modal: q("settingsModal"), reset_confirm_modal: q("resetConfirmModal"), cancel_reset_btn: q("cancelResetBtn"), confirm_reset_btn: q("confirmResetBtn"), play_again_btn: q("playAgainBtn"), results: q("results"), results_summary: q("resultsSummary"), wpm: q("wpm"), accuracy: q("accuracy"), combo: q("combo"), time: q("time"), profile_title: q("profileTitle"), level_text: q("levelText"), xp_text: q("xpText"), shard_text: q("shardText"), xp_fill: q("xpFill"), xp_gain: q("xpGain"), perks: q("perks"), profile_name: q("profileName"), difficulty_select: q("difficultySelect"), difficulty_bonus: q("difficultyBonus"), reset_progress_btn: q("resetProgressBtn") }; const xp_needed_for_level = (level) => 120 + (level - 1) * 60; const sanitize_difficulty = (v) => (DIFFICULTY_CONFIG[v] ? v : "normal"); const has_perk = (id) => state.owned_perks.includes(id); const open_settings = () => { dom.settings_modal.hidden = false; }; const close_settings = () => { dom.settings_modal.hidden = true; }; const open_reset_confirm = () => { dom.reset_confirm_modal.hidden = false; }; const close_reset_confirm = () => { dom.reset_confirm_modal.hidden = true; }; function save_progress() { localStorage.setItem(STORAGE_KEY, JSON.stringify({ profile_name: state.profile_name, difficulty: state.difficulty, xp: state.xp, level: state.level, shards: state.shards, last_round_xp: state.last_round_xp, owned_perks: state.owned_perks, stats: state.stats, recent_rounds: state.recent_rounds })); } function load_progress() { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; try { const parsed = JSON.parse(raw); state.profile_name = String(snake_or_camel(parsed, "profile_name", "profileName", DEFAULT_STATE.profile_name)).trim().slice(0, 24) || DEFAULT_STATE.profile_name; state.difficulty = sanitize_difficulty(parsed.difficulty); state.xp = Math.max(0, Math.floor(num(parsed.xp))); state.level = Math.max(1, Math.floor(num(parsed.level, 1))); state.shards = Math.max(0, Math.floor(num(parsed.shards))); state.last_round_xp = Math.max(0, Math.floor(num(snake_or_camel(parsed, "last_round_xp", "lastRoundXp")))); const owned = snake_or_camel(parsed, "owned_perks", "ownedPerks", []); state.owned_perks = Array.isArray(owned) ? owned.filter((id) => PERKS.some((p) => p.id === id)) : []; const stats = parsed.stats || {}; state.stats = { total_rounds: Math.max(0, Math.floor(num(snake_or_camel(stats, "total_rounds", "totalRounds")))), best_wpm: Math.max(0, Math.floor(num(snake_or_camel(stats, "best_wpm", "bestWpm")))), best_accuracy: Math.max(0, Math.floor(num(snake_or_camel(stats, "best_accuracy", "bestAccuracy")))), average_wpm: Math.max(0, num(snake_or_camel(stats, "average_wpm", "averageWpm"))), average_accuracy: Math.max(0, num(snake_or_camel(stats, "average_accuracy", "averageAccuracy"))) }; const rounds = snake_or_camel(parsed, "recent_rounds", "recentRounds", []); state.recent_rounds = Array.isArray(rounds) ? rounds.slice(0, 20).map((r) => ({ stamp: num(r.stamp, Date.now()), wpm: num(r.wpm), accuracy: num(r.accuracy), best_combo: num(snake_or_camel(r, "best_combo", "bestCombo")), xp: num(r.xp), difficulty: sanitize_difficulty(r.difficulty) })) : []; } catch { localStorage.removeItem(STORAGE_KEY); } } function pick_words() { const cfg = DIFFICULTY_CONFIG[state.difficulty]; const pool = WORD_BANKS[state.difficulty]; return Array.from({ length: cfg.round_words }, () => pool[Math.floor(Math.random() * pool.length)]); } function render_words() { dom.text_display.innerHTML = ""; state.words.forEach((word, idx) => { const span = document.createElement("span"); span.className = `word${idx === state.index ? " current" : ""}`; span.textContent = `${word} `; dom.text_display.appendChild(span); }); } function render_perks() { dom.perks.innerHTML = ""; PERKS.forEach((perk) => { const level_ready = state.level >= perk.unlock_level; const owned = has_perk(perk.id); const can_buy = level_ready && !owned && state.shards >= perk.cost; const card = document.createElement("article"); card.className = "perk"; card.innerHTML = `

${perk.name} ยท lv ${perk.unlock_level}

${perk.description}

cost: ${perk.cost} shard${perk.cost > 1 ? "s" : ""}
${owned ? "active" : level_ready ? "shop open" : "locked"}`; dom.perks.appendChild(card); }); } function render_settings() { dom.profile_name.value = state.profile_name; dom.difficulty_select.value = state.difficulty; dom.difficulty_bonus.textContent = `xp bonus: x${DIFFICULTY_CONFIG[state.difficulty].xp_mult.toFixed(2)}`; } function render_progress() { const level_need = xp_needed_for_level(state.level); dom.profile_title.textContent = state.profile_name; dom.level_text.textContent = `level ${state.level}`; dom.xp_text.textContent = `${state.xp} / ${level_need} xp`; dom.shard_text.textContent = `perk shards: ${state.shards}`; dom.xp_fill.style.width = `${Math.min((state.xp / level_need) * 100, 100)}%`; dom.xp_gain.textContent = `+${state.last_round_xp} xp last round`; render_settings(); render_perks(); } function update_live_stats() { const elapsed = Math.max(state.elapsed_seconds / 60, 1 / 60); const wpm = Math.round((state.typed_chars / 5) / elapsed); const total = state.correct_words + state.wrong_words; const accuracy = total ? Math.round((state.correct_words / total) * 100) : 100; dom.wpm.textContent = String(wpm); dom.accuracy.textContent = `${accuracy}%`; dom.combo.textContent = `x${state.combo}`; } function update_timer() { const remaining = Math.max(state.round_seconds - state.elapsed_seconds, 0); dom.time.textContent = `${remaining}s`; dom.time.style.color = remaining <= 10 ? "#ffd587" : ""; } function start_timer() { if (state.running) return; state.running = true; state.timer = setInterval(() => { state.elapsed_seconds += 1; update_timer(); update_live_stats(); if (state.elapsed_seconds >= state.round_seconds) finish_round(); }, 1000); } function award_xp(summary) { let xp = 35 + Math.floor(summary.wpm * 1.4) + Math.floor(summary.accuracy * 0.55) + Math.floor(summary.best_combo * 2.5); if (has_perk("combo-boost") && summary.best_combo >= 5) xp = Math.floor(xp * 1.15); if (has_perk("accuracy-bounty") && summary.accuracy >= 96) xp += 30; if (has_perk("focus-engine") && summary.accuracy >= 98) xp += Math.floor(summary.wpm * 0.25); xp = Math.floor(xp * DIFFICULTY_CONFIG[state.difficulty].xp_mult); let pending = xp; let level_ups = 0; while (pending > 0) { const need = xp_needed_for_level(state.level) - state.xp; if (pending >= need) { pending -= need; state.level += 1; state.xp = 0; level_ups += 1; } else { state.xp += pending; pending = 0; } } state.shards += level_ups; state.last_round_xp = xp; return xp; } function track_round(summary, earned_xp) { const s = state.stats; s.total_rounds += 1; s.best_wpm = Math.max(s.best_wpm, summary.wpm); s.best_accuracy = Math.max(s.best_accuracy, summary.accuracy); s.average_wpm = ((s.average_wpm * (s.total_rounds - 1)) + summary.wpm) / s.total_rounds; s.average_accuracy = ((s.average_accuracy * (s.total_rounds - 1)) + summary.accuracy) / s.total_rounds; state.recent_rounds.unshift({ stamp: Date.now(), wpm: summary.wpm, accuracy: summary.accuracy, best_combo: summary.best_combo, xp: earned_xp, difficulty: state.difficulty }); state.recent_rounds = state.recent_rounds.slice(0, 20); } function mark_word_result(correct) { const node = dom.text_display.querySelectorAll(".word")[state.index]; if (!node) return; node.classList.remove("current"); node.classList.add(correct ? "correct" : "incorrect"); } function set_current_word() { const node = dom.text_display.querySelectorAll(".word")[state.index]; if (!node) return; node.classList.add("current"); node.scrollIntoView({ block: "center", behavior: "smooth" }); } function finish_round() { clearInterval(state.timer); state.running = false; dom.type_input.disabled = true; const elapsed_m = Math.max(state.elapsed_seconds / 60, 1 / 60); const wpm = Math.round((state.typed_chars / 5) / elapsed_m); const total = state.correct_words + state.wrong_words; const accuracy = total ? Math.round((state.correct_words / total) * 100) : 100; const summary = { wpm, accuracy, best_combo: state.best_combo }; const earned_xp = award_xp(summary); track_round(summary, earned_xp); render_progress(); update_live_stats(); save_progress(); dom.results.hidden = false; dom.results_summary.textContent = `you reached ${wpm} wpm with ${accuracy}% accuracy and earned ${earned_xp} xp!`; } function reset_round() { clearInterval(state.timer); Object.assign(state, { words: pick_words(), index: 0, typed_chars: 0, correct_words: 0, wrong_words: 0, combo: 0, best_combo: 0, elapsed_seconds: 0, round_seconds: has_perk("time-warp") ? 68 : 60, running: false }); render_words(); update_live_stats(); update_timer(); dom.type_input.disabled = false; dom.type_input.value = ""; dom.type_input.focus(); dom.results.hidden = true; } function set_profile_name(value) { state.profile_name = value.trim().slice(0, 24) || DEFAULT_STATE.profile_name; render_progress(); save_progress(); } function set_difficulty(value) { state.difficulty = sanitize_difficulty(value); render_progress(); reset_round(); save_progress(); } function buy_perk(perk_id) { const perk = PERKS.find((p) => p.id === perk_id); if (!perk || has_perk(perk_id) || state.level < perk.unlock_level || state.shards < perk.cost) return; state.shards -= perk.cost; state.owned_perks.push(perk.id); render_progress(); reset_round(); save_progress(); } function reset_progress() { localStorage.removeItem(STORAGE_KEY); clearInterval(state.timer); Object.assign(state, { ...DEFAULT_STATE, stats: { ...DEFAULT_STATE.stats }, recent_rounds: [], owned_perks: [] }); render_progress(); reset_round(); save_progress(); } dom.type_input.addEventListener("input", () => { const value = dom.type_input.value; if (!state.running && value.length) start_timer(); if (!value.endsWith(" ")) return; const typed_word = value.trim(); const correct = typed_word === state.words[state.index]; state.typed_chars += typed_word.length + 1; if (correct) { state.correct_words += 1; state.combo += 1; state.best_combo = Math.max(state.best_combo, state.combo); } else { state.wrong_words += 1; state.combo = 0; } mark_word_result(correct); state.index += 1; dom.type_input.value = ""; if (state.index >= state.words.length) return finish_round(); set_current_word(); update_live_stats(); }); dom.new_round_btn.addEventListener("click", reset_round); dom.play_again_btn.addEventListener("click", reset_round); dom.settings_btn.addEventListener("click", open_settings); dom.close_settings_btn.addEventListener("click", close_settings); dom.profile_name.addEventListener("change", (e) => set_profile_name(e.target.value)); dom.difficulty_select.addEventListener("change", (e) => set_difficulty(e.target.value)); dom.reset_progress_btn.addEventListener("click", open_reset_confirm); dom.cancel_reset_btn.addEventListener("click", close_reset_confirm); dom.confirm_reset_btn.addEventListener("click", () => { reset_progress(); close_reset_confirm(); close_settings(); }); dom.settings_modal.addEventListener("click", (e) => e.target === dom.settings_modal && close_settings()); dom.reset_confirm_modal.addEventListener("click", (e) => e.target === dom.reset_confirm_modal && close_reset_confirm()); document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; if (!dom.reset_confirm_modal.hidden) return close_reset_confirm(); if (!dom.settings_modal.hidden) close_settings(); }); dom.perks.addEventListener("click", (e) => { const target = e.target; if (!(target instanceof HTMLButtonElement)) return; const perk_id = target.dataset.perkId; if (perk_id) buy_perk(perk_id); }); load_progress(); render_progress(); reset_round();