← back to Free Roster Memory
Free-Roster emoji memory game: complete, accessible, best-score persistence
fbea953d22acd5481a3ad98a50411ac5b378d86d · 2026-07-24 23:22:28 -0700 · Steve
Files touched
Diff
commit fbea953d22acd5481a3ad98a50411ac5b378d86d
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jul 24 23:22:28 2026 -0700
Free-Roster emoji memory game: complete, accessible, best-score persistence
---
.gitignore | 8 ++
index.html | 315 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 323 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..7cbb19a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,315 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Free-Roster — Emoji Memory Match</title>
+<style>
+ :root{
+ --bg1:#1e1b4b; --bg2:#312e81; --accent:#a78bfa; --accent2:#f472b6;
+ --card-back:linear-gradient(135deg,#7c3aed,#db2777);
+ --card-face:#ffffff; --matched:#34d399; --miss:#f87171;
+ }
+ *{box-sizing:border-box;margin:0;padding:0}
+ body{
+ min-height:100vh;
+ font-family:'Segoe UI',system-ui,-apple-system,sans-serif;
+ background:linear-gradient(160deg,var(--bg1),var(--bg2));
+ color:#f8fafc;
+ display:flex;flex-direction:column;align-items:center;
+ padding:20px;gap:16px;
+ }
+ h1{
+ font-size:clamp(1.6rem,5vw,2.6rem);
+ font-weight:800;letter-spacing:.5px;
+ background:linear-gradient(90deg,var(--accent),var(--accent2));
+ -webkit-background-clip:text;background-clip:text;color:transparent;
+ text-align:center;
+ }
+ .controls{
+ display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:center;
+ }
+ .seg{
+ display:inline-flex;background:rgba(255,255,255,.08);
+ border-radius:999px;padding:4px;gap:4px;
+ border:1px solid rgba(255,255,255,.12);
+ }
+ .seg button{
+ border:none;background:transparent;color:#e2e8f0;
+ padding:8px 18px;border-radius:999px;cursor:pointer;
+ font-size:.95rem;font-weight:600;transition:.2s;
+ }
+ .seg button.active{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;box-shadow:0 4px 14px rgba(167,139,250,.4)}
+ .seg button:focus-visible,.btn:focus-visible{outline:3px solid #fde68a;outline-offset:2px}
+ .btn{
+ border:none;cursor:pointer;font-weight:700;font-size:.95rem;
+ padding:10px 22px;border-radius:999px;color:#fff;
+ background:linear-gradient(135deg,var(--accent),var(--accent2));
+ box-shadow:0 4px 14px rgba(244,114,182,.35);transition:transform .15s,box-shadow .15s;
+ }
+ .btn:hover{transform:translateY(-2px);box-shadow:0 8px 22px rgba(244,114,182,.5)}
+ .btn:active{transform:translateY(0)}
+ .stats{
+ display:flex;gap:22px;font-size:1.05rem;font-weight:600;flex-wrap:wrap;justify-content:center;
+ background:rgba(255,255,255,.07);padding:12px 26px;border-radius:16px;
+ border:1px solid rgba(255,255,255,.1);
+ }
+ .stats span{color:var(--accent);font-variant-numeric:tabular-nums}
+ .stats .best{color:#fcd34d}
+ .board-wrap{position:relative;width:100%;max-width:560px}
+ #board{
+ display:grid;gap:10px;perspective:900px;width:100%;
+ }
+ #board.locked{pointer-events:none}
+ .card{
+ position:relative;aspect-ratio:1;cursor:pointer;
+ transform-style:preserve-3d;transition:transform .45s cubic-bezier(.4,.2,.2,1);
+ border-radius:14px;border:none;padding:0;background:transparent;
+ }
+ .card.flipped,.card.matched{transform:rotateY(180deg)}
+ .card.matched{cursor:default}
+ .card:focus-visible{outline:3px solid #fde68a;outline-offset:3px}
+ .card.miss{animation:shake .4s}
+ @keyframes shake{
+ 0%,100%{transform:rotateY(180deg) translateX(0)}
+ 25%{transform:rotateY(180deg) translateX(-7px)}
+ 75%{transform:rotateY(180deg) translateX(7px)}
+ }
+ .face{
+ position:absolute;inset:0;border-radius:14px;
+ display:flex;align-items:center;justify-content:center;
+ backface-visibility:hidden;-webkit-backface-visibility:hidden;
+ user-select:none;
+ }
+ .back{
+ background:var(--card-back);
+ box-shadow:0 4px 12px rgba(0,0,0,.3), inset 0 0 0 2px rgba(255,255,255,.15);
+ }
+ .back::after{content:"?";font-size:clamp(1.2rem,5vw,2rem);font-weight:800;color:rgba(255,255,255,.85)}
+ .front{
+ background:var(--card-face);color:#111;
+ transform:rotateY(180deg);
+ font-size:clamp(1.4rem,6vw,2.8rem);
+ box-shadow:0 4px 12px rgba(0,0,0,.3);
+ }
+ .card.miss .front{background:var(--miss)}
+ .card.matched .front{background:var(--matched);animation:pop .4s ease}
+ @keyframes pop{0%{transform:rotateY(180deg) scale(1)}50%{transform:rotateY(180deg) scale(1.12)}100%{transform:rotateY(180deg) scale(1)}}
+ .overlay{
+ position:fixed;inset:0;background:rgba(15,12,41,.8);
+ display:flex;align-items:center;justify-content:center;
+ opacity:0;pointer-events:none;transition:opacity .35s;backdrop-filter:blur(4px);z-index:10;
+ }
+ .overlay.show{opacity:1;pointer-events:auto}
+ .win-card{
+ background:linear-gradient(160deg,#312e81,#1e1b4b);
+ border:1px solid rgba(255,255,255,.15);
+ padding:36px 40px;border-radius:24px;text-align:center;
+ box-shadow:0 20px 60px rgba(0,0,0,.5);transform:scale(.85);transition:transform .35s;
+ max-width:90vw;
+ }
+ .overlay.show .win-card{transform:scale(1)}
+ .win-card h2{font-size:2rem;margin-bottom:8px}
+ .win-card .emoji{font-size:3rem;margin-bottom:6px}
+ .win-card p{color:#cbd5e1;margin-bottom:6px;font-size:1.05rem}
+ .win-card p b{color:var(--accent)}
+ .win-card .newbest{color:#fcd34d;font-weight:700;margin-top:4px}
+ .win-card .btn{margin-top:18px}
+ .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
+ footer{font-size:.8rem;color:rgba(255,255,255,.4);margin-top:6px}
+</style>
+</head>
+<body>
+ <h1>🧠 Emoji Memory Match</h1>
+
+ <div class="controls">
+ <div class="seg" id="difficulty" role="group" aria-label="Difficulty">
+ <button data-size="4" class="active" aria-pressed="true">4 × 4</button>
+ <button data-size="6" aria-pressed="false">6 × 6</button>
+ </div>
+ <button class="btn" id="restart">↻ Restart</button>
+ </div>
+
+ <div class="stats" aria-hidden="true">
+ <div>Moves: <span id="moves">0</span></div>
+ <div>Time: <span id="time">0:00</span></div>
+ <div>Pairs: <span id="pairs">0 / 0</span></div>
+ <div class="best">Best: <span id="best">—</span></div>
+ </div>
+
+ <div class="board-wrap">
+ <div id="board" role="grid" aria-label="Memory board"></div>
+ </div>
+
+ <div class="sr-only" role="status" aria-live="polite" id="live"></div>
+
+ <div class="overlay" id="overlay" role="dialog" aria-modal="true" aria-labelledby="winTitle">
+ <div class="win-card">
+ <div class="emoji">🎉</div>
+ <h2 id="winTitle">You Won!</h2>
+ <p>Solved in <b id="winMoves">0</b> moves</p>
+ <p>Time: <b id="winTime">0:00</b></p>
+ <p class="newbest" id="newBest" hidden>🏆 New best time!</p>
+ <button class="btn" id="playAgain">Play Again</button>
+ </div>
+ </div>
+
+ <footer>Free-Roster · flip two cards to find a match · use mouse or keyboard</footer>
+
+<script>
+ const EMOJIS = ['🍕','🎸','🚀','🐱','🌈','⚽','🎮','🍩','🦊','🌵','🐙','🎈',
+ '🍔','🦄','🎧','🍦','🐳','🔥','🌸','🎲','🧩','🍭','🐢','🎁',
+ '🐝','🌙','🍉','🎯','🦋','🍄','⭐','🐧'];
+
+ const board = document.getElementById('board');
+ const movesEl = document.getElementById('moves');
+ const timeEl = document.getElementById('time');
+ const pairsEl = document.getElementById('pairs');
+ const bestEl = document.getElementById('best');
+ const liveEl = document.getElementById('live');
+ const overlay = document.getElementById('overlay');
+
+ let size = 4, first = null, lock = false;
+ let moves = 0, matched = 0, totalPairs = 0;
+ let timer = null, seconds = 0, started = false;
+
+ const fmt = s => Math.floor(s/60) + ':' + String(s%60).padStart(2,'0');
+ const bestKey = () => 'freeRoster.best.' + size;
+ const say = msg => { liveEl.textContent = msg; };
+
+ function shuffle(a){
+ for(let i=a.length-1;i>0;i--){
+ const j = Math.floor(Math.random()*(i+1));
+ [a[i],a[j]]=[a[j],a[i]];
+ }
+ return a;
+ }
+
+ function loadBest(){
+ const raw = localStorage.getItem(bestKey());
+ if(!raw){ bestEl.textContent = '—'; return null; }
+ try{
+ const b = JSON.parse(raw);
+ bestEl.textContent = fmt(b.seconds) + ' · ' + b.moves + ' mv';
+ return b;
+ }catch{ bestEl.textContent = '—'; return null; }
+ }
+
+ function startTimer(){
+ if(started) return;
+ started = true;
+ timer = setInterval(()=>{ seconds++; timeEl.textContent = fmt(seconds); }, 1000);
+ }
+ function stopTimer(){ clearInterval(timer); timer = null; }
+
+ function newGame(){
+ stopTimer();
+ started = false; seconds = 0; moves = 0; matched = 0; first = null; lock = false;
+ totalPairs = (size*size)/2;
+ movesEl.textContent = '0';
+ timeEl.textContent = '0:00';
+ pairsEl.textContent = '0 / ' + totalPairs;
+ overlay.classList.remove('show');
+ board.classList.remove('locked');
+ loadBest();
+
+ const chosen = shuffle([...EMOJIS]).slice(0, totalPairs);
+ const deck = shuffle([...chosen, ...chosen]);
+
+ board.style.gridTemplateColumns = `repeat(${size}, 1fr)`;
+ board.innerHTML = '';
+
+ deck.forEach((emoji)=>{
+ const card = document.createElement('button');
+ card.className = 'card';
+ card.type = 'button';
+ card.dataset.emoji = emoji;
+ card.setAttribute('aria-label', 'Hidden card');
+ card.innerHTML = `<span class="face back" aria-hidden="true"></span><span class="face front" aria-hidden="true">${emoji}</span>`;
+ card.addEventListener('click', ()=>flip(card));
+ board.appendChild(card);
+ });
+ say(size + ' by ' + size + ' game started. ' + totalPairs + ' pairs.');
+ }
+
+ function flip(card){
+ if(lock) return;
+ if(card.classList.contains('flipped') || card.classList.contains('matched')) return;
+
+ startTimer();
+ card.classList.add('flipped');
+ card.setAttribute('aria-label', card.dataset.emoji);
+
+ if(!first){ first = card; return; }
+ if(first === card) return;
+
+ moves++;
+ movesEl.textContent = moves;
+
+ if(first.dataset.emoji === card.dataset.emoji){
+ const a = first; first = null;
+ a.classList.add('matched');
+ card.classList.add('matched');
+ a.setAttribute('aria-label', a.dataset.emoji + ', matched');
+ card.setAttribute('aria-label', card.dataset.emoji + ', matched');
+ matched++;
+ pairsEl.textContent = matched + ' / ' + totalPairs;
+ say('Match! ' + matched + ' of ' + totalPairs + ' pairs.');
+ if(matched === totalPairs) win();
+ } else {
+ lock = true;
+ board.classList.add('locked');
+ const a = first, b = card;
+ first = null;
+ a.classList.add('miss'); b.classList.add('miss');
+ say('No match.');
+ setTimeout(()=>{
+ [a,b].forEach(c=>{
+ c.classList.remove('flipped','miss');
+ c.setAttribute('aria-label','Hidden card');
+ });
+ lock = false;
+ board.classList.remove('locked');
+ }, 800);
+ }
+ }
+
+ function win(){
+ stopTimer();
+ const prev = loadBest();
+ const isBest = !prev || seconds < prev.seconds ||
+ (seconds === prev.seconds && moves < prev.moves);
+ if(isBest){
+ localStorage.setItem(bestKey(), JSON.stringify({seconds, moves}));
+ loadBest();
+ }
+ document.getElementById('winMoves').textContent = moves;
+ document.getElementById('winTime').textContent = fmt(seconds);
+ document.getElementById('newBest').hidden = !isBest;
+ say('You won in ' + moves + ' moves and ' + fmt(seconds) + '.');
+ setTimeout(()=>{ overlay.classList.add('show'); document.getElementById('playAgain').focus(); }, 500);
+ }
+
+ document.getElementById('difficulty').addEventListener('click', (e)=>{
+ const b = e.target.closest('button');
+ if(!b) return;
+ document.querySelectorAll('#difficulty button').forEach(x=>{
+ const on = x === b;
+ x.classList.toggle('active', on);
+ x.setAttribute('aria-pressed', on ? 'true' : 'false');
+ });
+ size = parseInt(b.dataset.size, 10);
+ newGame();
+ });
+
+ document.getElementById('restart').addEventListener('click', newGame);
+ document.getElementById('playAgain').addEventListener('click', newGame);
+ // Close win overlay with Escape → restart
+ document.addEventListener('keydown', (e)=>{
+ if(e.key === 'Escape' && overlay.classList.contains('show')) newGame();
+ });
+
+ newGame();
+</script>
+</body>
+</html>
(oldest)
·
back to Free Roster Memory
·
Add light/dark theme toggle, Web Audio sound effects, and 8x eb1e20f →