Board & Cards
Status: COMPLETE — Full design documented
Last updated: August 6, 2026
Flip Match -- Board & Card System
Status: COMPLETE — Full design documented
Source: Extracted from Java source code. All constants, algorithms, and logic verified against actual implementation.
Key classes:
MemorySnapBoard,MemorySnapCard, ,MemorySnapCardItem,MemorySnapCardTheme,MemorySnapDifficulty
Grid Configuration
Difficulty-to-Board Mapping
Defined in MemorySnapDifficulty enum (llitd.model.memorysnap.enums.MemorySnapDifficulty):
| Enum Value | boardSize |
totalPairs |
Total Cards | timeLimitSeconds |
parTimeSeconds |
hintsAvailable |
scoreMultiplier |
|---|---|---|---|---|---|---|---|
EASY |
4 | 8 | 16 | 0 (no limit) | 60 | 3 | 1.0 |
MEDIUM |
6 | 18 | 36 | 240 (4 min) | 120 | 2 | 1.5 |
HARD |
8 | 32 | 64 | 300 (5 min) | 150 | 1 | 2.0 |
Constructor signature:
MemorySnapDifficulty(int boardSize, int totalPairs, int timeLimitSeconds,
int parTimeSeconds, int hintsAvailable, double scoreMultiplier)
Fallback behavior: fromBoardSize(int size) iterates all enum values, returns MEDIUM if no match found. This means passing an invalid board size (e.g., 5) silently defaults to MEDIUM (6x6).
Time Limit Mechanics
From MemorySnapGameSession:
hasTimeLimit(): returnstimeLimitSeconds > 0(EASY has 0, so no limit)isTimeExpired():calculateTimeElapsed() > timeLimitSeconds-- uses strict greater-than, so finishing exactly at the limit is NOT expiredcalculateTimeElapsed():ChronoUnit.SECONDS.between(startedAt, endTime)whereendTimeiscompletedAt(if set) orLocalDateTime.now()getTimeRemaining(): returnsnullfor untimed games (EASY), otherwiseMath.max(0, timeLimitSeconds - calculateTimeElapsed())
Time expiration is checked lazily -- only when flipCard() is called on . There is no background timer. If a player walks away from a HARD game and returns 10 minutes later, the game auto-abandons on their next flip attempt.
// From .flipCard():
if (session.isTimeExpired()) {
session.abandonGame();
sessionMapper.abandonSession(session.getSessionId());
return MemorySnapFlipResult.error("Time's up! Game over.", ...);
}
Coordinate System
Mapping Logic
Defined in MemorySnapBoard static methods:
positionToCoordinate(row, col):
column = (char)('A' + col) // col 0 = A, col 7 = H
row = row + 1 // row 0 = 1, row 7 = 8
return column + row // e.g., (0,0) -> "A1", (2,1) -> "B3"
coordinateToPosition(coord):
normalized = coord.toUpperCase().trim()
col = normalized.charAt(0) - 'A' // A=0, B=1, ..., H=7
row = parseInt(normalized.substring(1)) - 1 // 1=0, 2=1, ..., 8=7
return [row, col]
Validation constraints in coordinateToPosition:
- Input must be 2-3 characters long (rejects "A", "AB12", etc.)
- Column char must be A-H (rejects 'I' through 'Z')
- Row must be parseable as integer
- Returns
nullon any failure ( caught)
Board-level validation in isValidCoordinate:
- Calls
coordinateToPositionfirst (basic format check) - Then checks
pos[0] >= 0 && pos[0] < size && pos[1] >= 0 && pos[1] < size - This means "H8" is valid on an 8x8 board but invalid on a 4x4 board
Coordinate Parsing in Handlers
The uses a regex pattern:
private static final Pattern COORDINATE_PATTERN = Pattern.compile("([A-Ha-h])\\s*([1-8])");
This matches coordinates embedded in natural language input: "flip A1" extracts "A1", "turn over B 3" extracts "B3" (allows whitespace between letter and number). The handler extracts from gameBasicProperties.getStoryKeySelected().getBasicValue().
Voice-form coordinate routing (NLU Tier 1c). Bare coordinates spoken naturally must reach the flip handler before the option matcher consumes them. A real Alexa speaker says "alpha one", "a one", "a 1", or "alpha 1" — and the spelled number "one" was being matched as numbered option index 1, so "alpha one" routed to STATUS and "a one" routed to HINT, burning a hint credit (). now resolves bare coordinates in a Tier 1c pre-match (before Tier 2 OPTIONS) whenever a Memory Snap account is active. It accepts a single letter or NATO phonetic word (a/alpha .. h/hotel) for the column and a digit or spelled number word (1/one .. 8/eight) for the row, in concatenated (a1), spaced (a 1), or two-token (alpha one) form. Non-coordinate input returns null and falls through to normal NLU, so status, hint, help still route correctly.
Either order is accepted (column-first or row-first). The game announces cells column-first ("Say a coordinate like A1 or B3") and players say them back both ways round, so "1 a", "3 h", "1a", and "one alpha" resolve to the same cell as "a1", "h3", and "alpha one". parseMemorySnapCoordinate tries the column-first reading first and falls back to the row-first reading. There is no ambiguity to resolve: a column is always a letter and a row is always a number, so a token can only ever be one of the two, and a pair that fails both readings ("hi", "1 1", "a a") still returns null. Rejecting the row-first order sent a bare coordinate straight to FALLBACK, which makes the grid unplayable rather than merely awkward, because naming a cell is the game's core interaction (player feedback 11207 and 11208; 15 NLU fallbacks on 1 A/1 B/3 B/3 H).
Forms with three or more tokens are still rejected. "1 pair a" (2 fallbacks, one player, from a menu state with no live board) is unrecoverable garble, not a coordinate — routing it to a guessed cell would invent a feature the game does not have.
Grid Layout
A B C D (E) (F) (G) (H)
+----+----+----+----+
1 |card|card|card|card| ... extends to board size
+----+----+----+----+
2 |card|card|card|card|
+----+----+----+----+
3 |card|card|card|card|
+----+----+----+----+
4 |card|card|card|card|
+----+----+----+----+
Internal storage: MemorySnapCard[row][col] -- row is the number axis, col is the letter axis.
ASCII Art Rendering
MemorySnapBoard.toAsciiArt() generates a formatted board using box-drawing characters:
- Column headers:
" A "format with 2-char padding - Border chars:
+-----+top/bottom,|-----+mid-row, using Unicode box chars - Cell content:
" {emoji} "-- 1 space padding on each side - Hidden cards display as
"card-back"emoji - Row numbers on left side:
"1 |","2 |", etc.
Display-only — never spoken. The ASCII grid is visual content for display clients only. The flip/status/start handlers append status.getBoardAscii() to the response text only when isVoiceRequest() is false (MCP playtest, web UI, Discord). On a real Alexa request the grid is omitted so the box-drawing characters are never read aloud. For the same reason the inline flip and hint responses carry the card displayName only — the card emoji is not prepended to spoken text (). Emoji still appear in the visual grid for display clients; they just never reach outputSpeech.
Card Model
MemorySnapCard
Source: llitd.model.memorysnap.MemorySnapCard
| Field | Type | Default | Purpose |
|---|---|---|---|
itemId |
int | -- | Unique identifier for the card content |
itemName |
String | -- | Internal name (e.g., "lion") |
displayName |
String | -- | Human-readable name (e.g., "Lion") |
emoji |
String | -- | Emoji representation |
revealed |
boolean | false |
Currently face-up (temporary state) |
matched |
boolean | false |
Successfully paired (permanent state) |
Card States
HIDDEN: revealed=false, matched=false -> displays "card-back" emoji
REVEALED: revealed=true, matched=false -> displays item emoji (temporary)
MATCHED: revealed=true, matched=true -> displays item emoji (permanent)
Display logic from getDisplay():
if (matched || revealed) {
return emoji != null ? emoji : displayName; // fallback to text if no emoji
}
return "card-back-emoji"; // hidden card face-down
Card Matching
public boolean matches(MemorySnapCard other) {
if (other == null) return false;
return this.itemId == other.itemId; // match by itemId only
}
Matching is purely by itemId -- two cards from the same MemorySnapCardItem share the same itemId. No partial matching or similar-item mechanics exist.
Card Creation
MemorySnapCard.fromItem(MemorySnapCardItem item) creates a card with:
itemId,itemName,displayName,emojicopied from itemrevealed = false,matched = false
Theme System
Theme Enum (Hardcoded Subset)
MemorySnapTheme enum provides a hardcoded list for parsing user input:
| Enum | Display Name | Description | Premium |
|---|---|---|---|
ANIMALS |
"Animals" | "Cute and wild animals from around the world" | false |
FLOWERS |
"Flowers" | "Beautiful flowers and blossoms" | false |
FOOD |
"Food" | "Delicious foods and treats" | false |
TRANSPORT |
"Transport" | "Vehicles and ways to travel" | false |
MYTHICAL_CREATURES |
"Mythical Creatures" | "Dragons, phoenixes, unicorns, and legendary beasts" | true (subscriber) |
SPACE |
"Space" | "Planets, rockets, astronauts, and cosmic wonders" | true (subscriber) |
OCEAN |
"Ocean" | "Sea creatures, coral reefs, and underwater treasures" | true (subscriber) |
DINOSAURS |
"Dinosaurs" | "Prehistoric giants from the age of reptiles" | true (subscriber) |
fromString(value): case-insensitive parse, returns ANIMALS as default on null/blank/invalid input.
Theme selection in handlers: The parses theme from user input using substring matching:
"animal"-> ANIMALS"flower"-> FLOWERS"food"-> FOOD"transport"or"vehicle"-> TRANSPORT
If no theme keyword found, falls back to account.getPreferredTheme() (default: "ANIMALS").
Difficulty Weight
Each MemorySnapCardItem has a difficultyWeight field, but it is not currently used in board generation. The getRandomItemsForTheme query selects items with ORDER BY RAND() LIMIT #{count} -- purely random selection with no weight consideration.
Theme Availability
getActiveThemes(): SQL query filters byis_active = TRUEunlockRequirementfield exists onMemorySnapCardThemebut no code reads or enforces it- All four default themes are presumed active
Board Generation
Algorithm
Source: .generateBoard(difficulty, themeName)
Step 1: Resolve theme
MemorySnapCardTheme theme = themeMapper.getThemeByName(themeName)
.orElseThrow(() -> new ("Theme not found: " + themeName));
Throws if theme name doesn't exist in DB.
Step 2: Fetch random items
List<MemorySnapCardItem> items = itemMapper.getRandomItemsForTheme(theme.getThemeId(), totalPairs);
SQL: SELECT ... FROM memory_snap.card_item WHERE theme_id = #{themeId} ORDER BY RAND() LIMIT #{count}
Step 3: Handle insufficient items
if (items.size() < totalPairs) {
log.warn("Not enough items for theme {}. Needed {}, got {}", themeName, totalPairs, items.size());
while (items.size() < totalPairs) {
items.addAll(itemMapper.getRandomItemsForTheme(theme.getThemeId(),
Math.min(totalPairs - items.size(), 5)));
}
}
If a theme has fewer items than needed (e.g., HARD needs 32 but theme has 20), the generator loops and re-queries for more random items. This will produce duplicate items (same item appearing as 4+ cards instead of 2), which breaks the pair-matching assumption.
Step 4: Create card pairs
for (int i = 0; i < totalPairs; i++) {
MemorySnapCardItem item = items.get(i);
cardList.add(MemorySnapCard.fromItem(item)); // Card A
cardList.add(MemorySnapCard.fromItem(item)); // Card B (identical)
}
Each item produces exactly 2 cards. Cards are distinct objects but share the same itemId.
Step 5: Shuffle
Collections.shuffle(cardList); // Uses default Random
Standard Fisher-Yates shuffle via java.util.Collections.shuffle(). No seed control -- purely random each time.
Step 6: Place on grid
MemorySnapCard[][] cards = new MemorySnapCard[size][size];
int index = 0;
for (int row = 0; row < size; row++) {
for (int col = 0; col < size; col++) {
cards[row][col] = cardList.get(index++);
}
}
Row-major order fill. No spatial constraints -- pairs can end up adjacent or on opposite corners.
Step 7: Build board
return MemorySnapBoard.builder()
.size(size)
.cards(cards)
.matchedPositions(new HashSet<>()) // empty
.currentStreak(0)
.build();
Testing Variant
generateBoardWithItems(int size, List<MemorySnapCardItem> items):
- Same algorithm but accepts items directly (no DB lookup)
- Calculates
totalPairs = (size * size) / 2from size parameter - Throws if not enough items (stricter than main method which loops)
Flip Mechanics
State Machine
The board tracks flip state via firstFlip and secondFlip fields:
State: AWAITING_FIRST_FLIP (firstFlip == null)
-> Player flips card
-> Card revealed, firstFlip set to coordinate
-> Return: success, isSecondFlip=false, isMatch=false
State: AWAITING_SECOND_FLIP (firstFlip != null, secondFlip == null)
-> Player flips card
-> Card revealed, secondFlip set to coordinate
-> Check match:
MATCH:
-> Both cards set to matched=true
-> Both coordinates added to matchedPositions
-> currentStreak++
-> firstFlip/secondFlip reset to null
-> Check isGameComplete()
-> Return: success, isSecondFlip=true, isMatch=true
NO MATCH:
-> currentStreak = 0
-> Return: success, isSecondFlip=true, isMatch=false
-> (caller must call hideUnmatchedCards() afterward)
Flip Validation
MemorySnapBoard.flipCard(coordinate) performs these checks in order:
- Invalid coordinate:
!isValidCoordinate(coordinate)-- returns error with valid range message - Already matched:
matchedPositions.contains(coordinate.toUpperCase())-- returns "already matched" error - Card not found:
getCardAt(coordinate) == null-- returns "could not find card" error (defensive, shouldn't happen) - Same card twice:
firstFlip.equalsIgnoreCase(coordinate)-- returns "already flipped that card" error - All checks pass -- proceed with flip
Match Detection
boolean isMatch = card.matches(firstCard); // Delegates to itemId comparison
Post-Flip Processing
In , after board.flipCard() returns:
if (result.isSuccess() && result.isSecondFlip()) {
session.incrementTurns(); // turnsTaken++
// Track longest streak
if (board.getCurrentStreak() > session.getLongestStreak()) {
session.setLongestStreak(board.getCurrentStreak());
}
if (!result.isMatch()) {
board.hideUnmatchedCards(); // Reset ALL non-matched cards to hidden
}
if (result.isGameComplete()) {
completeGame(session);
result.setScore(session.getScore());
}
}
saveSession(session); // Always save after any flip
Important: hideUnmatchedCards() iterates the ENTIRE board and hides ALL non-matched cards (not just the two just flipped). This is correct because only the two just-flipped cards should be revealed (plus matched ones), but it's a full board scan on every mismatch.
public void hideUnmatchedCards() {
for (int row = 0; row < size; row++) {
for (int col = 0; col < size; col++) {
MemorySnapCard card = cards[row][col];
if (card != null && !card.isMatched()) {
card.setRevealed(false);
}
}
}
firstFlip = null;
secondFlip = null;
}
Turn Counting
Turns increment on the second flip only (whether match or not). First flip does not count as a turn. This means:
- Minimum turns = number of pairs (perfect game: every second flip is a match)
- Each failed attempt costs 1 turn
Game Completion
isGameComplete():
return matchedPositions.size() == totalCards; // size * size
Checks that every position on the board has been matched. Since each match adds 2 positions, completion requires totalPairs successful matches.
Board Serialization
Player View (Safe Output)
MemorySnapBoard.getPlayerView() creates a MemorySnapBoardView that contains:
| Field | Type | Source |
|---|---|---|
size |
int | Board size |
displayGrid |
List<List<String>> |
Emoji for revealed/matched, card-back for hidden |
coordinateGrid |
List<List<String>> |
A1, B1, C1, ... coordinate labels |
pairsFound |
int | matchedPositions.size() / 2 |
pairsRemaining |
int | getPairsRemaining() |
currentStreak |
int | Current consecutive match count |
awaitingSecondFlip |
boolean | firstFlip != null |
firstFlipCoordinate |
String | First flip coordinate (if awaiting second) |
This view is safe to send to the client -- it does not expose hidden card identities.
Session Management
One Active Game Per Player
enforces this:
Optional<MemorySnapGameSession> activeSession = sessionMapper.getActiveSessionForAccount(account.getAccountId());
if (activeSession.isPresent()) {
sessionMapper.abandonSession(activeSession.get().getSessionId());
log.info("Abandoned previous active session...");
}
Starting a new game auto-abandons any existing active session. No confirmation prompt.
Session Cleanup
removes abandoned sessions older than the specified number of days. Called for maintenance, not triggered automatically during gameplay.