Hidden mini-game: "Who's that Pokémon?"

Guess the Pokémon from its silhouette (official artwork, filter:brightness(0)
until revealed). Streak + best + win% persisted in pdx.whosThat. Three
misses auto-reveal; hints (letter count, then first letter) after wrong
guesses; guess matching is punctuation/space/case-insensitive.

Not in the nav or any link. Reached by the Konami code (↑↑↓↓←→←→BA,
lib/konami.js wired in main.js), seven quick taps on the nav wordmark, or
the bare #/whos-that hash once you know it.

9 new unit tests for the konami matcher (sequence, case-insensitivity,
rewind, partial).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
chris 2026-09-10 12:34:45 -04:00
parent bbc4cb48ca
commit fb299d11f9
8 changed files with 386 additions and 1 deletions

View File

@ -25,6 +25,20 @@ export function Nav() {
const nav = el('nav', { class: 'nav', 'aria-label': 'Primary' });
let links = [];
// Easter egg: seven quick taps on the wordmark opens the hidden game
// (the Konami code does the same on a keyboard).
let taps = 0;
let tapTimer = null;
function bumpBrand() {
taps += 1;
clearTimeout(tapTimer);
tapTimer = setTimeout(() => (taps = 0), 1500);
if (taps >= 7) {
taps = 0;
location.hash = '#/whos-that';
}
}
function build() {
const st = settings.get();
const items = ITEMS.filter((it) => !it.optional || st[it.optional]);
@ -41,7 +55,10 @@ export function Nav() {
)
: el('a', { class: 'nav__link', href: item.href }, ...inner);
});
nav.replaceChildren(el('span', { class: 'nav__brand' }, 'Pocketdex'), ...links);
nav.replaceChildren(
el('span', { class: 'nav__brand', onclick: bumpBrand }, 'Pocketdex'),
...links,
);
nav._items = items;
sync();
}

42
src/lib/konami.js Normal file
View File

@ -0,0 +1,42 @@
/** ↑ ↑ ↓ ↓ ← → ← → B A */
export const KONAMI_SEQUENCE = [
'arrowup',
'arrowup',
'arrowdown',
'arrowdown',
'arrowleft',
'arrowright',
'arrowleft',
'arrowright',
'b',
'a',
];
/**
* Returns a `(key) => void` that calls `onUnlock` once the full sequence
* has been entered in order. A wrong key rewinds (but a stray "↑" still
* counts as the start of a fresh attempt). Pure no DOM.
*/
export function konamiHandler(onUnlock) {
let i = 0;
return (key) => {
const k = String(key).toLowerCase();
if (k === KONAMI_SEQUENCE[i]) {
i += 1;
if (i === KONAMI_SEQUENCE.length) {
i = 0;
onUnlock();
}
} else {
i = k === KONAMI_SEQUENCE[0] ? 1 : 0;
}
};
}
/** Wire {@link konamiHandler} to window keydowns. Returns an unsubscribe. */
export function onKonami(onUnlock) {
const step = konamiHandler(onUnlock);
const listener = (e) => step(e.key);
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}

View File

@ -9,6 +9,7 @@ import { settings, applyTheme } from './store/settings.js';
import { ui } from './store/ui.js';
import { markPlayed } from './store/playedGames.js';
import { onInstallChange, promptInstall } from './lib/install.js';
import { onKonami } from './lib/konami.js';
applyTheme();
@ -51,6 +52,11 @@ app.append(Nav(), viewHost);
initRouter(viewHost);
// --- Easter egg: Konami code jumps to the hidden mini-game --------------
onKonami(() => {
if ((location.hash || '') !== '#/whos-that') location.hash = '#/whos-that';
});
// --- Service worker: prompt to refresh rather than silently swapping ------
const updateSW = registerSW({
onNeedRefresh() {

View File

@ -13,6 +13,7 @@ import { CompareView } from './views/CompareView.js';
import { ProgressView } from './views/ProgressView.js';
import { BreedingView } from './views/BreedingView.js';
import { ShinyView } from './views/ShinyView.js';
import { WhosThatView } from './views/WhosThatView.js';
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
import { prefersReducedMotion } from './store/settings.js';
@ -39,6 +40,7 @@ const routes = [
{ pattern: /^#\/shiny$/, view: () => ShinyView() },
{ pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() },
{ pattern: /^#\/whos-that$/, view: () => WhosThatView() }, // hidden — Konami / logo taps
];
function resolve(hash) {

13
src/store/whosThat.js Normal file
View File

@ -0,0 +1,13 @@
import { createStore } from './createStore.js';
/** Score-keeping for the hidden "Who's that Pokémon?" game (#/whos-that). */
export const whosThat = createStore('pdx.whosThat', { best: 0, plays: 0, wins: 0 });
export function recordResult(win, streak) {
whosThat.set((s) => ({
...s,
plays: s.plays + 1,
wins: s.wins + (win ? 1 : 0),
best: Math.max(s.best, streak),
}));
}

View File

@ -3735,3 +3735,103 @@
width: 100%;
justify-content: center;
}
/* ---- Hidden mini-game: Who's that Pokémon? (#/whos-that) ---------- */
.wtp {
max-width: 30rem;
margin: 0 auto;
}
.wtp__score {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 4px 0 14px;
}
.wtp__pill {
padding: 4px 10px;
border-radius: 999px;
background: var(--surface-2);
font-size: 0.78rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.wtp__pill--dim {
background: none;
color: var(--text-dim);
font-weight: 600;
}
.wtp__stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 22px 16px;
border-radius: var(--radius-lg);
background:
radial-gradient(
60% 55% at 50% 42%,
color-mix(in srgb, var(--accent) 22%, transparent),
transparent 70%
),
var(--surface);
border: 1px solid var(--border);
}
.wtp__art {
width: 260px;
max-width: 78vw;
aspect-ratio: 1;
display: grid;
place-items: center;
}
.wtp__art img {
width: 100%;
height: 100%;
object-fit: contain;
filter: brightness(0);
transition: filter 0.5s ease;
}
.wtp__art.is-revealed img {
filter: none;
}
.wtp__caption {
font-weight: 800;
font-size: 1.05rem;
text-align: center;
letter-spacing: 0.01em;
}
.wtp__hint {
min-height: 1.1em;
font-size: 0.82rem;
color: var(--text-dim);
}
.wtp__controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.wtp__input {
flex: 1 1 12rem;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
font: inherit;
}
.wtp__input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.wtp__feedback {
min-height: 1.4em;
margin-top: 10px;
font-weight: 700;
text-align: center;
}
.wtp__feedback.is-good {
color: var(--good);
}
.wtp__feedback.is-bad {
color: var(--danger);
}

157
src/views/WhosThatView.js Normal file
View File

@ -0,0 +1,157 @@
import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { Sprite } from '../components/Sprite.js';
import { whosThat, recordResult } from '../store/whosThat.js';
import { prefersReducedMotion } from '../store/settings.js';
const loose = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
const pretty = (name) => name.replace(/-/g, ' ');
/**
* Hidden mini-game reachable by the Konami code (BA) or seven
* quick taps on the nav wordmark, and by the bare #/whos-that hash once
* you know it. Guess the Pokémon from its silhouette.
*/
export async function WhosThatView() {
const view = el('section', { class: 'view wtp' });
const snap = await loadSnapshot();
const pool = snap.species;
let current = null;
let revealed = false;
let wrong = 0;
let streak = 0;
const art = el('div', { class: 'wtp__art' });
const caption = el('div', { class: 'wtp__caption' }, 'Whos that Pokémon?');
const hint = el('div', { class: 'wtp__hint' });
const feedback = el('div', { class: 'wtp__feedback' });
const scoreEl = el('div', { class: 'wtp__score' });
const input = el('input', {
class: 'wtp__input',
type: 'text',
placeholder: 'Your guess…',
autocomplete: 'off',
autocapitalize: 'off',
autocorrect: 'off',
spellcheck: 'false',
onkeydown: (e) => {
if (e.key === 'Enter') submitGuess();
},
});
const guessBtn = el(
'button',
{ class: 'button wtp__go', type: 'button', onclick: submitGuess },
'Guess',
);
const revealBtn = el(
'button',
{ class: 'button button--ghost', type: 'button', onclick: () => reveal(false) },
'Reveal',
);
const nextBtn = el('button', { class: 'button', type: 'button', onclick: next }, 'Next →');
function renderScore() {
const s = whosThat.get();
const pct = s.plays ? Math.round((s.wins / s.plays) * 100) : 0;
clear(scoreEl).append(
el('span', { class: 'wtp__pill' }, `Streak ${streak}`),
el('span', { class: 'wtp__pill' }, `Best ${s.best}`),
el('span', { class: 'wtp__pill wtp__pill--dim' }, `${s.wins}/${s.plays} · ${pct}%`),
);
}
function syncButtons() {
input.hidden = revealed;
guessBtn.hidden = revealed;
revealBtn.hidden = revealed;
nextBtn.hidden = !revealed;
}
function next() {
current = pool[Math.floor(Math.random() * pool.length)];
revealed = false;
wrong = 0;
art.classList.remove('is-revealed');
clear(art).append(Sprite(current.id, { style: 'official', size: 260, alt: 'Mystery Pokémon' }));
caption.textContent = 'Whos that Pokémon?';
hint.textContent = '';
feedback.textContent = '';
feedback.className = 'wtp__feedback';
input.value = '';
syncButtons();
input.focus();
}
function reveal(win) {
revealed = true;
art.classList.add('is-revealed');
caption.textContent = `Its ${pretty(current.name)}! #${String(current.id).padStart(4, '0')}`;
if (win) {
streak += 1;
feedback.textContent = wrong === 0 ? 'Nailed it!' : 'Got there!';
feedback.className = 'wtp__feedback is-good';
} else {
streak = 0;
feedback.textContent = 'That one got away.';
feedback.className = 'wtp__feedback is-bad';
}
recordResult(win, streak);
renderScore();
syncButtons();
if (win && !prefersReducedMotion()) {
art.animate(
[{ transform: 'scale(0.9)' }, { transform: 'scale(1.05)' }, { transform: 'scale(1)' }],
{ duration: 380, easing: 'cubic-bezier(.34,1.4,.64,1)' },
);
}
nextBtn.focus();
}
function submitGuess() {
if (revealed || !input.value.trim()) return;
if (loose(input.value) === loose(current.name)) {
reveal(true);
return;
}
wrong += 1;
input.value = '';
feedback.className = 'wtp__feedback is-bad';
if (wrong === 1) {
feedback.textContent = 'Nope — try again.';
hint.textContent = `${loose(current.name).length} letters`;
} else if (wrong === 2) {
feedback.textContent = 'Last clue…';
hint.textContent = `Starts with “${current.name[0].toUpperCase()}”, ${loose(current.name).length} letters`;
} else {
reveal(false); // three strikes
return;
}
input.focus();
}
view.append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/' }, ' Dex')),
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Whos that Pokémon?'),
el('p', {}, 'Name it from the silhouette. Three misses and its revealed.'),
),
scoreEl,
el('div', { class: 'wtp__stage' }, art, caption, hint),
el('div', { class: 'wtp__controls' }, input, guessBtn, revealBtn, nextBtn),
feedback,
);
renderScore();
next();
const off = whosThat.subscribe(() => {
if (revealed) renderScore();
});
onTeardown(view, off);
return view;
}

48
test/konami.test.js Normal file
View File

@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { konamiHandler, KONAMI_SEQUENCE } from '../src/lib/konami.js';
describe('konamiHandler', () => {
it('fires once the full sequence is entered', () => {
let hits = 0;
const step = konamiHandler(() => hits++);
KONAMI_SEQUENCE.forEach((k) => step(k));
expect(hits).toBe(1);
});
it('is case-insensitive and fires again on a repeat', () => {
let hits = 0;
const step = konamiHandler(() => hits++);
const upper = [
'ArrowUp',
'ArrowUp',
'ArrowDown',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowLeft',
'ArrowRight',
'B',
'A',
];
upper.forEach((k) => step(k));
upper.forEach((k) => step(k));
expect(hits).toBe(2);
});
it('a wrong key rewinds, but a stray ↑ starts a fresh attempt', () => {
let hits = 0;
const step = konamiHandler(() => hits++);
step('arrowup');
step('arrowdown'); // wrong (expected another arrowup) -> reset
step('x'); // noise
KONAMI_SEQUENCE.forEach((k) => step(k)); // clean run
expect(hits).toBe(1);
});
it('does not fire on a partial sequence', () => {
let hits = 0;
const step = konamiHandler(() => hits++);
KONAMI_SEQUENCE.slice(0, -1).forEach((k) => step(k));
expect(hits).toBe(0);
});
});