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