How to Play
- A grid of cards will flash open briefly — memorize the symbol on each card!
- After they flip back, find all the matching pairs from memory.
+ A grid of cards will flash open briefly — memorize where each image is!
+ After they flip back, find all the matching groups of three from memory.
- Watch closely while the cards are revealed.
- - After they flip face-down, click two cards you think share the same symbol.
- - Matched pairs stay revealed. Wrong guesses flip back.
- - Find every pair to advance — grids grow and reveal time shrinks each level!
+ - After they flip face-down, click three cards you think share the same image.
+ - Matched groups stay revealed. Wrong guesses flip back.
+ - Find every group to advance — grids grow and reveal time shrinks each level!
- Use Tab to move between cards and Enter or Space
to select.
@@ -33,8 +33,8 @@
How to Play
Level: 1
Score: 0
- Pairs: 0 /
- 0
+ Groups: 0 /
+ 0
diff --git a/app/games/high-speed-memory/style.css b/app/games/high-speed-memory/style.css
index 2b04adf..5db7ff0 100644
--- a/app/games/high-speed-memory/style.css
+++ b/app/games/high-speed-memory/style.css
@@ -3,14 +3,17 @@
display: flex;
flex-direction: column;
align-items: center;
- gap: 1rem;
- padding: 1.5rem;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
background-color: #f8f9fa;
color: #212529; /* ~14.5:1 contrast on #f8f9fa */
+ /* Fill the full height of the game container */
+ min-height: calc(100vh - 160px);
+ box-sizing: border-box;
}
.high-speed-memory h2 {
- font-size: 1.75rem;
+ font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
@@ -61,6 +64,17 @@
font-size: 1.1rem;
}
+/* ── Game area ───────────────────────────────────────────────── */
+#hsm-game-area {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+ flex: 1;
+ width: 100%;
+ min-height: 0;
+}
+
/* ── Stats bar ───────────────────────────────────────────────── */
.hsm-stats {
display: flex;
@@ -77,11 +91,11 @@
/* ── Countdown banner ────────────────────────────────────────── */
.hsm-countdown {
- padding: 0.5rem 1.5rem;
+ padding: 0.4rem 1.25rem;
background-color: #1a1a2e;
color: #ffffff; /* 18:1 contrast */
border-radius: 6px;
- font-size: 1.1rem;
+ font-size: 1rem;
font-weight: 600;
text-align: center;
width: 100%;
@@ -91,77 +105,72 @@
/* ── Card grid ───────────────────────────────────────────────── */
.hsm-grid {
display: grid;
- gap: 0.5rem;
- justify-content: center;
+ gap: 0.4rem;
+ /* Responsive square grid that fills available space */
+ width: min(90vw, calc(100vh - 260px));
+ height: min(90vw, calc(100vh - 260px));
+ max-width: 100%;
+ margin: 0 auto;
+ flex-shrink: 0;
}
/* ── Individual card ─────────────────────────────────────────── */
.hsm-card {
- width: 72px;
- height: 72px;
+ position: relative;
border: none;
- border-radius: 8px;
- font-size: 1.75rem;
+ border-radius: 6px;
cursor: pointer;
+ overflow: hidden;
+ background-color: #1a1a2e;
+ transition: transform 0.1s ease, background-color 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
- transition: transform 0.15s ease, background-color 0.15s ease;
- background-color: #1a1a2e;
- color: #1a1a2e; /* hide symbol by default — face-down */
- position: relative;
- user-select: none;
+ padding: 0;
}
-/* Face-down: show question mark pattern */
-.hsm-card::after {
- content: '?';
- position: absolute;
- font-size: 1.5rem;
- color: #e94560; /* 4.6:1 on #1a1a2e */
+/* Card image fills the button */
+.hsm-card__img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
pointer-events: none;
+ user-select: none;
}
-/* Face-up (revealed or being guessed): show the symbol */
+/* Face-up revealed state */
.hsm-card--revealed {
background-color: #ffffff;
- color: #212529; /* ~16:1 contrast on #ffffff */
-}
-
-.hsm-card--revealed::after {
- display: none;
}
/* Matched pair: green background */
.hsm-card--matched {
background-color: #d4edda;
- color: #155724; /* 7.3:1 contrast on #d4edda */
cursor: default;
+ outline: 2px solid #28a745;
}
-.hsm-card--matched::after {
- display: none;
+.hsm-card--matched .hsm-card__img {
+ opacity: 0.85;
}
-/* Wrong-guess flash: brief red tint (applied via JS, removed after animation) */
+/* Wrong guess: red tint (no animation per game spec) */
.hsm-card--wrong {
background-color: #f8d7da;
- color: #721c24; /* 7.3:1 contrast on #f8d7da */
- animation: hsm-shake 0.3s ease-out;
-}
-
-.hsm-card--wrong::after {
- display: none;
+ outline: 2px solid #dc3545;
}
-@keyframes hsm-shake {
- 0%, 100% { transform: translateX(0); }
- 25% { transform: translateX(-4px); }
- 75% { transform: translateX(4px); }
+/* Empty placeholder cell (fills unused grid slot) */
+.hsm-card--empty {
+ background-color: transparent;
+ border: 1px dashed #dee2e6;
+ cursor: default;
+ pointer-events: none;
}
-.hsm-card:hover:not(.hsm-card--matched):not([disabled]) {
- transform: scale(1.05);
+.hsm-card:hover:not(.hsm-card--matched):not(.hsm-card--empty):not([disabled]) {
+ transform: scale(1.04);
}
.hsm-card:focus-visible {
@@ -251,3 +260,4 @@
[hidden] {
display: none !important;
}
+
diff --git a/app/games/high-speed-memory/tests/game.test.js b/app/games/high-speed-memory/tests/game.test.js
index c0b4883..9b0a9be 100644
--- a/app/games/high-speed-memory/tests/game.test.js
+++ b/app/games/high-speed-memory/tests/game.test.js
@@ -4,8 +4,8 @@ import {
} from '@jest/globals';
import {
- SYMBOLS,
- GRID_CONFIGS,
+ CARD_IMAGES,
+ MATCH_SIZE,
BASE_DISPLAY_MS,
DISPLAY_DECREMENT_MS,
MIN_DISPLAY_MS,
@@ -13,10 +13,11 @@ import {
startGame,
stopGame,
getGridSize,
+ getActiveCardCount,
getDisplayDurationMs,
generateGrid,
checkMatch,
- addCorrectPair,
+ addCorrectGroup,
completeRound,
getScore,
getLevel,
@@ -30,23 +31,35 @@ beforeEach(() => {
// ── Constants ─────────────────────────────────────────────────────────────────
-describe('SYMBOLS', () => {
+describe('CARD_IMAGES', () => {
test('is an array of strings', () => {
- expect(Array.isArray(SYMBOLS)).toBe(true);
- SYMBOLS.forEach((s) => expect(typeof s).toBe('string'));
+ expect(Array.isArray(CARD_IMAGES)).toBe(true);
+ CARD_IMAGES.forEach((s) => expect(typeof s).toBe('string'));
});
- test('has at least as many symbols as the maximum pair count needed', () => {
- const maxPairs = Math.max(...GRID_CONFIGS.map(([r, c]) => (r * c) / 2));
- expect(SYMBOLS.length).toBeGreaterThanOrEqual(maxPairs);
+ test('has enough images for a level-9 grid (12x12 = 48 groups)', () => {
+ const level9Groups = getActiveCardCount(9) / MATCH_SIZE;
+ expect(CARD_IMAGES.length).toBeGreaterThanOrEqual(level9Groups);
});
});
-describe('GRID_CONFIGS', () => {
- test('every config produces an even number of cards', () => {
- GRID_CONFIGS.forEach(([rows, cols]) => {
- expect((rows * cols) % 2).toBe(0);
- });
+describe('MATCH_SIZE', () => {
+ test('is 3', () => {
+ expect(MATCH_SIZE).toBe(3);
+ });
+});
+
+describe('display timing constants', () => {
+ test('BASE_DISPLAY_MS is 500', () => {
+ expect(BASE_DISPLAY_MS).toBe(500);
+ });
+
+ test('MIN_DISPLAY_MS is 20', () => {
+ expect(MIN_DISPLAY_MS).toBe(20);
+ });
+
+ test('DISPLAY_DECREMENT_MS is a positive number', () => {
+ expect(DISPLAY_DECREMENT_MS).toBeGreaterThan(0);
});
});
@@ -54,7 +67,7 @@ describe('GRID_CONFIGS', () => {
describe('initGame', () => {
test('resets score to 0', () => {
- addCorrectPair();
+ addCorrectGroup();
initGame();
expect(getScore()).toBe(0);
});
@@ -119,8 +132,8 @@ describe('stopGame', () => {
test('includes the current score in the result', () => {
startGame();
- addCorrectPair();
- addCorrectPair();
+ addCorrectGroup();
+ addCorrectGroup();
const result = stopGame();
expect(result.score).toBe(2);
});
@@ -136,25 +149,50 @@ describe('stopGame', () => {
// ── getGridSize ───────────────────────────────────────────────────────────────
describe('getGridSize', () => {
- test('returns rows and cols for level 0', () => {
- const { rows, cols } = getGridSize(0);
- expect(rows).toBe(GRID_CONFIGS[0][0]);
- expect(cols).toBe(GRID_CONFIGS[0][1]);
+ test('returns 3×3 for level 0', () => {
+ expect(getGridSize(0)).toEqual({ rows: 3, cols: 3 });
+ });
+
+ test('returns 4×4 for level 1', () => {
+ expect(getGridSize(1)).toEqual({ rows: 4, cols: 4 });
});
- test('clamps to the last config for very high levels', () => {
- const last = GRID_CONFIGS[GRID_CONFIGS.length - 1];
- const { rows, cols } = getGridSize(9999);
- expect(rows).toBe(last[0]);
- expect(cols).toBe(last[1]);
+ test('returns 5×5 for level 2', () => {
+ expect(getGridSize(2)).toEqual({ rows: 5, cols: 5 });
});
- test('returns the correct config for every defined level', () => {
- GRID_CONFIGS.forEach(([r, c], i) => {
+ test('rows always equal cols (square grid)', () => {
+ for (let i = 0; i < 10; i += 1) {
const { rows, cols } = getGridSize(i);
- expect(rows).toBe(r);
- expect(cols).toBe(c);
- });
+ expect(rows).toBe(cols);
+ }
+ });
+
+ test('grid grows with each level', () => {
+ for (let i = 0; i < 9; i += 1) {
+ expect(getGridSize(i + 1).rows).toBeGreaterThan(getGridSize(i).rows);
+ }
+ });
+});
+
+// ── getActiveCardCount ────────────────────────────────────────────────────────
+
+describe('getActiveCardCount', () => {
+ test('is always divisible by MATCH_SIZE', () => {
+ for (let i = 0; i < 10; i += 1) {
+ expect(getActiveCardCount(i) % MATCH_SIZE).toBe(0);
+ }
+ });
+
+ test('is at most rows×cols', () => {
+ for (let i = 0; i < 10; i += 1) {
+ const { rows, cols } = getGridSize(i);
+ expect(getActiveCardCount(i)).toBeLessThanOrEqual(rows * cols);
+ }
+ });
+
+ test('level 0 (3×3=9) returns 9', () => {
+ expect(getActiveCardCount(0)).toBe(9);
});
});
@@ -172,24 +210,27 @@ describe('getDisplayDurationMs', () => {
test('never goes below MIN_DISPLAY_MS', () => {
expect(getDisplayDurationMs(9999)).toBe(MIN_DISPLAY_MS);
});
+
+ test('reaches minimum at high levels', () => {
+ const levelsToMin = Math.ceil((BASE_DISPLAY_MS - MIN_DISPLAY_MS) / DISPLAY_DECREMENT_MS);
+ expect(getDisplayDurationMs(levelsToMin + 5)).toBe(MIN_DISPLAY_MS);
+ });
});
// ── generateGrid ──────────────────────────────────────────────────────────────
describe('generateGrid', () => {
- test('returns the correct number of cards for the level', () => {
- const { rows, cols } = getGridSize(0);
- const grid = generateGrid(0);
- expect(grid.length).toBe(rows * cols);
+ test('returns getActiveCardCount cards', () => {
+ expect(generateGrid(0).length).toBe(getActiveCardCount(0));
});
- test('each symbol appears exactly twice', () => {
+ test('each image appears exactly MATCH_SIZE times', () => {
const grid = generateGrid(0);
const counts = {};
- grid.forEach(({ symbol }) => {
- counts[symbol] = (counts[symbol] || 0) + 1;
+ grid.forEach(({ image }) => {
+ counts[image] = (counts[image] || 0) + 1;
});
- Object.values(counts).forEach((count) => expect(count).toBe(2));
+ Object.values(counts).forEach((count) => expect(count).toBe(MATCH_SIZE));
});
test('all cards start as unmatched', () => {
@@ -202,10 +243,17 @@ describe('generateGrid', () => {
grid.forEach((card, i) => expect(card.id).toBe(i));
});
- test('produces grids for every defined level', () => {
- GRID_CONFIGS.forEach((_, i) => {
- const { rows, cols } = getGridSize(i);
- expect(generateGrid(i).length).toBe(rows * cols);
+ test('each card has an image property that is a non-empty string', () => {
+ const grid = generateGrid(0);
+ grid.forEach((card) => {
+ expect(typeof card.image).toBe('string');
+ expect(card.image.length).toBeGreaterThan(0);
+ });
+ });
+
+ test('produces correct card count for several levels', () => {
+ [0, 1, 2, 3, 4].forEach((lvl) => {
+ expect(generateGrid(lvl).length).toBe(getActiveCardCount(lvl));
});
});
});
@@ -213,27 +261,40 @@ describe('generateGrid', () => {
// ── checkMatch ────────────────────────────────────────────────────────────────
describe('checkMatch', () => {
- test('returns true for equal symbols', () => {
- expect(checkMatch('★', '★')).toBe(true);
+ test('returns true when all MATCH_SIZE images are equal', () => {
+ expect(checkMatch('card-01.svg', 'card-01.svg', 'card-01.svg')).toBe(true);
+ });
+
+ test('returns false when any image differs', () => {
+ expect(checkMatch('card-01.svg', 'card-01.svg', 'card-02.svg')).toBe(false);
+ });
+
+ test('returns false when first and last differ', () => {
+ expect(checkMatch('card-01.svg', 'card-02.svg', 'card-01.svg')).toBe(false);
});
- test('returns false for different symbols', () => {
- expect(checkMatch('★', '♠')).toBe(false);
+ test('returns false with fewer than MATCH_SIZE arguments', () => {
+ expect(checkMatch('card-01.svg', 'card-01.svg')).toBe(false);
+ });
+
+ test('returns false with more than MATCH_SIZE arguments all equal', () => {
+ const args = Array(MATCH_SIZE + 1).fill('card-01.svg');
+ expect(checkMatch(...args)).toBe(false);
});
});
-// ── addCorrectPair ────────────────────────────────────────────────────────────
+// ── addCorrectGroup ───────────────────────────────────────────────────────────
-describe('addCorrectPair', () => {
+describe('addCorrectGroup', () => {
test('increments score by 1', () => {
- addCorrectPair();
+ addCorrectGroup();
expect(getScore()).toBe(1);
});
test('accumulates across multiple calls', () => {
- addCorrectPair();
- addCorrectPair();
- addCorrectPair();
+ addCorrectGroup();
+ addCorrectGroup();
+ addCorrectGroup();
expect(getScore()).toBe(3);
});
});
@@ -295,3 +356,5 @@ describe('isRunning', () => {
expect(isRunning()).toBe(false);
});
});
+
+
diff --git a/app/games/high-speed-memory/tests/index.test.js b/app/games/high-speed-memory/tests/index.test.js
index 77c35cd..155df4a 100644
--- a/app/games/high-speed-memory/tests/index.test.js
+++ b/app/games/high-speed-memory/tests/index.test.js
@@ -2,24 +2,33 @@ import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globa
// Mock game.js so index.js can be tested in isolation.
jest.unstable_mockModule('../game.js', () => ({
- SYMBOLS: ['★', '♠', '♥', '♦', '♣', '☀', '☽', '✿', '♪', '✈', '⚽', '🎯', '🔔', '🌊', '🍀', '💎'],
- GRID_CONFIGS: [[2, 2], [2, 3]],
- BASE_DISPLAY_MS: 3000,
- DISPLAY_DECREMENT_MS: 200,
- MIN_DISPLAY_MS: 800,
+ CARD_IMAGES: [
+ 'card-01.svg', 'card-02.svg', 'card-03.svg',
+ 'card-04.svg', 'card-05.svg', 'card-06.svg',
+ ],
+ MATCH_SIZE: 3,
+ BASE_DISPLAY_MS: 500,
+ DISPLAY_DECREMENT_MS: 24,
+ MIN_DISPLAY_MS: 20,
initGame: jest.fn(),
startGame: jest.fn(),
stopGame: jest.fn(() => ({ score: 5, level: 2, roundsCompleted: 2, duration: 12000 })),
- getGridSize: jest.fn(() => ({ rows: 2, cols: 2 })),
- getDisplayDurationMs: jest.fn(() => 3000),
+ getGridSize: jest.fn(() => ({ rows: 3, cols: 3 })),
+ getActiveCardCount: jest.fn(() => 9),
+ getDisplayDurationMs: jest.fn(() => 500),
generateGrid: jest.fn(() => [
- { id: 0, symbol: '★', matched: false },
- { id: 1, symbol: '♠', matched: false },
- { id: 2, symbol: '★', matched: false },
- { id: 3, symbol: '♠', matched: false },
+ { id: 0, image: 'card-01.svg', matched: false },
+ { id: 1, image: 'card-02.svg', matched: false },
+ { id: 2, image: 'card-03.svg', matched: false },
+ { id: 3, image: 'card-01.svg', matched: false },
+ { id: 4, image: 'card-02.svg', matched: false },
+ { id: 5, image: 'card-03.svg', matched: false },
+ { id: 6, image: 'card-01.svg', matched: false },
+ { id: 7, image: 'card-02.svg', matched: false },
+ { id: 8, image: 'card-03.svg', matched: false },
]),
- checkMatch: jest.fn((a, b) => a === b),
- addCorrectPair: jest.fn(),
+ checkMatch: jest.fn((a, b, c) => a === b && b === c),
+ addCorrectGroup: jest.fn(),
completeRound: jest.fn(),
getScore: jest.fn(() => 5),
getLevel: jest.fn(() => 2),
@@ -32,7 +41,7 @@ const plugin = pluginModule.default;
const {
announce,
updateStats,
- updatePairsDisplay,
+ updateGroupsDisplay,
renderGrid,
hideCardEl,
revealCardEl,
@@ -41,6 +50,7 @@ const {
hideAllCards,
startRound,
handleCardClick,
+ playWrongSound,
} = pluginModule;
const gameMock = await import('../game.js');
@@ -60,8 +70,8 @@ function buildContainer() {