Scoring
Status: IN PROGRESS — Tracked open issues; see Known Issues section
Last updated: May 17, 2026
Flip Match -- Scoring, Achievements & Leaderboard
Status: IN PROGRESS — Tracked open issues; see Known Issues section
Source: Extracted from Java source code. All formulas, constants, and SQL verified against actual implementation.
Key classes: ,
MemorySnapAchievement,MemorySnapAccountAchievement,MemorySnapLeaderboardEntry,MemorySnapAccount,
Scoring Constants
Source: ()
private static final int BASE_SCORE = 1000;
private static final int POINTS_PER_EXTRA_TURN = 10;
private static final int POINTS_PER_EXTRA_SECOND = 1;
private static final int POINTS_PER_HINT = 50;
private static final int PERFECT_GAME_BONUS = 500;
private static final int UNDER_PAR_TIME_BONUS = 100;
private static final int NO_HINTS_BONUS = 100;
private static final int POINTS_PER_STREAK = 10;
Final Score Formula
`calculateFinalScore(MemorySnapGameSession session)`
Called once when a game is completed ().
INPUTS:
difficulty = session.getDifficulty() // EASY | MEDIUM | HARD
totalPairs = session.getTotalPairs() // 8 | 18 | 32
turnsTaken = session.getTurnsTaken() // actual turns used
timeElapsed = session.getTimeElapsedSeconds() // seconds since start
hintsUsed = session.getHintsUsed() // hints consumed
longestStreak = session.getLongestStreak() // best consecutive match run
scoreMultiplier = difficulty.getScoreMultiplier() // 1.0 | 1.5 | 2.0
DERIVED:
minimumTurns = totalPairs // 1 turn per pair in perfect game
parTime = difficulty.getParTimeSeconds() // EASY:60, MEDIUM:120, HARD:150
DEDUCTIONS:
extraTurns = max(0, turnsTaken - minimumTurns)
turnDeduction = extraTurns * 10
extraSeconds = max(0, timeElapsed - parTime)
timeDeduction = extraSeconds * 1
hintDeduction = hintsUsed * 50
BONUSES:
perfectBonus = (turnsTaken == minimumTurns) ? 500 : 0
underParBonus = (timeElapsed < parTime) ? 100 : 0 // strict less-than
noHintsBonus = (hintsUsed == 0) ? 100 : 0
streakBonus = longestStreak * 10
FORMULA:
rawScore = 1000 + perfectBonus + underParBonus + noHintsBonus + streakBonus
- turnDeduction - timeDeduction - hintDeduction
finalScore = max(0, (int)(rawScore * scoreMultiplier))
Execution Order
The code computes in this exact sequence:
int score = BASE_SCORE; // Start at 1000
// Deductions applied first
score -= extraTurns * POINTS_PER_EXTRA_TURN; // -10 per extra turn
score -= extraSeconds * POINTS_PER_EXTRA_SECOND; // -1 per extra second
score -= hintsUsed * POINTS_PER_HINT; // -50 per hint
// Bonuses applied second
if (turnsTaken == minimumTurns) score += PERFECT_GAME_BONUS; // +500
if (timeElapsed < parTime) score += UNDER_PAR_TIME_BONUS; // +100
if (hintsUsed == 0) score += NO_HINTS_BONUS; // +100
score += longestStreak * POINTS_PER_STREAK; // +10 per streak match
// Multiplier and floor applied last
score = (int)(score * difficulty.getScoreMultiplier());
return Math.max(0, score);
Note on integer truncation: The (int) cast truncates rather than rounds. A raw score of 1001 with 1.5x multiplier produces (int)(1501.5) = 1501, not 1502.
Par Time System
Par Time Per Difficulty
Par times are now stored directly on the MemorySnapDifficulty enum as parTimeSeconds, rather than calculated from a per-pair rate:
int parTime = difficulty.getParTimeSeconds();
| Difficulty | Pairs | Par Time | Time Limit | Headroom | Human-Readable Par |
|---|---|---|---|---|---|
| EASY | 8 | 60s | None | Unlimited | 1:00 |
| MEDIUM | 18 | 120s | 240s | 120s (2:00) | 2:00 |
| HARD | 32 | 150s | 300s | 150s (2:30) | 2:30 |
Relationship to time limit: Par time is the "speed bonus" threshold. Time limit is the "game over" boundary. Par is always well below the time limit, ensuring both are meaningful constraints.
In-Progress Score
`calculateCurrentScore(MemorySnapGameSession session)`
Used for live display during gameplay (status handler):
int pairsFound = session.getPairsFound();
if (pairsFound == 0) return 0;
int scorePerPair = 50;
int score = pairsFound * scorePerPair;
// Add current streak bonus (not longest -- uses live board streak)
if (session.getBoard() != null) {
score += session.getBoard().getCurrentStreak() * POINTS_PER_STREAK;
}
// Apply difficulty multiplier
score = (int)(score * session.getDifficulty().getScoreMultiplier());
return score;
This is a completely different formula from the final score. The in-progress score is a simplified estimate:
- 50 points per pair found (not the 1000-base deduction model)
- Uses current streak (not longest)
- No turn/time/hint deductions
- Still applies difficulty multiplier
| Progress | EASY | MEDIUM | HARD |
|---|---|---|---|
| 1 pair found, no streak | 50 | 75 | 100 |
| 4 pairs found, 3 streak | 230 | 345 | 460 |
| All pairs, 5 streak | 450 | 1,425 | 3,300 |
Streak Mechanics
In-Game Streak (Board Level)
Tracked on MemorySnapBoard.currentStreak:
// On match (inside board.flipCard):
currentStreak++;
// On mismatch:
currentStreak = 0;
The streak is a simple counter that increments on every consecutive match and resets to zero on any mismatch.
Session Longest Streak
Tracked on MemorySnapGameSession.longestStreak:
// In .flipCard(), after board.flipCard():
if (board.getCurrentStreak() > session.getLongestStreak()) {
session.setLongestStreak(board.getCurrentStreak());
}
Also updated at game completion:
// In session.completeGame():
if (board != null) {
this.longestStreak = Math.max(longestStreak, board.getCurrentStreak());
}
Streak Bonus in Final Score
Only the longest streak contributes to the final score:
streakBonus = longestStreak * 10
This means:
- A streak of 1 (single match): +10 points
- A streak of 5 (five in a row): +50 points
- A streak of 8 (all EASY pairs perfect): +80 points
- A streak of 32 (all HARD pairs perfect): +320 points
Note: A perfect game (all pairs matched on first try) has longestStreak == totalPairs AND earns the +500 perfect game bonus. The streak bonus compounds with the perfect bonus.
Streak Display in Flip Results
From MemorySnapBoard.flipCard():
String streakMsg = currentStreak > 1 ? " " + currentStreak + " in a row!" : "";
Streak is only displayed when greater than 1 (first match in a streak shows no streak message).
Account Win Streak (Cross-Game)
MemorySnapAccount tracks a separate win streak (consecutive COMPLETED games):
public void recordGameCompletion(boolean won, int matchesFound, int streak) {
totalGamesPlayed++;
if (won) {
totalGamesWon++;
currentStreak++;
if (currentStreak > longestStreak) {
longestStreak = currentStreak;
}
} else {
currentStreak = 0;
}
totalMatchesFound += matchesFound;
}
This account-level streak is separate from the in-game match streak. Abandoning a game resets the win streak to 0.
Hint System Economics
Allocation Per Game
| Difficulty | Hints Available | Score Penalty Per Hint |
|---|---|---|
| EASY | 3 | -50 |
| MEDIUM | 2 | -50 |
| HARD | 1 | -50 |
Bonus for Not Using Hints
if (session.getHintsUsed() == 0) {
score += NO_HINTS_BONUS; // +100
}
This is binary: using even 1 hint loses the full +100 bonus. The total cost of first hint = 50 (penalty) + 100 (lost bonus) = 150 points effective cost.
Maximum Hint Cost by Difficulty
| Difficulty | Max Hints | Direct Cost | Lost Bonus | Total Max Cost | Pre-Multiplier |
|---|---|---|---|---|---|
| EASY | 3 | -150 | -100 | -250 | -250 |
| MEDIUM | 2 | -100 | -100 | -200 | -300 |
| HARD | 1 | -50 | -100 | -150 | -300 |
Hint Implementation
reveals BOTH coordinates of one unmatched pair:
- Calls
board.findUnmatchedPairCoordinates()to locate a matching pair by itemId - Returns the card identity plus both coordinates (e.g., "Hint: 🦁 Lion is at A1 and C3!")
- Increments
hintsUsedand deducts -50 from final score per hint - Cards do NOT stay flipped — player must remember the locations and flip them on their own turn
findRandomUnmatchedCardCoordinate() also exists on the board model but is not used by the hint flow; it remains as a utility helper.
Login Reward Hint Credits
From :
| Login Streak | Base Reward | Streak Bonus | Total Hint Credits |
|---|---|---|---|
| Any login | 1 | -- | 1 |
| 3+ days | 1 | +1 | 2 |
| 7+ days | 1 | +2 | 3 |
applyCurrencyPayout() increments account.hintCredits. On startNewGame(), bonus hints from account credits are applied (capped at 3 per game) on top of the difficulty's base hint allocation.
Score Examples (Verified Against Code)
Perfect EASY Game
Inputs: EASY, 8 turns (minimum), 45s elapsed, 0 hints, streak=8
parTime = 60s (from difficulty.getParTimeSeconds())
score = 1000
- extraTurns: max(0, 8-8) * 10 = 0
- extraSeconds: max(0, 45-60) * 1 = 0
- hints: 0 * 50 = 0
+ perfectBonus: 500 (turnsTaken == minimumTurns)
+ underParBonus: 100 (45 < 60)
+ noHintsBonus: 100 (hintsUsed == 0)
+ streakBonus: 8 * 10 = 80
rawScore = 1000 + 500 + 100 + 100 + 80 = 1780
finalScore = (int)(1780 * 1.0) = 1780
Good MEDIUM Game
Inputs: MEDIUM, 25 turns, 110s elapsed, 0 hints, streak=4
parTime = 120s (from difficulty.getParTimeSeconds())
minimumTurns = 18
score = 1000
- extraTurns: max(0, 25-18) * 10 = 70
- extraSeconds: max(0, 110-120) * 1 = 0
- hints: 0 * 50 = 0
+ perfectBonus: 0 (25 != 18)
+ underParBonus: 100 (110 < 120)
+ noHintsBonus: 100 (0 hints)
+ streakBonus: 4 * 10 = 40
rawScore = 1000 - 70 + 100 + 100 + 40 = 1170
finalScore = (int)(1170 * 1.5) = 1755
Struggling HARD Game
Inputs: HARD, 50 turns, 200s elapsed, 1 hint, streak=3
parTime = 150s (from difficulty.getParTimeSeconds())
minimumTurns = 32
score = 1000
- extraTurns: max(0, 50-32) * 10 = 180
- extraSeconds: max(0, 200-150) * 1 = 50
- hints: 1 * 50 = 50
+ perfectBonus: 0
+ underParBonus: 0 (200 > 150)
+ noHintsBonus: 0 (1 hint used)
+ streakBonus: 3 * 10 = 30
rawScore = 1000 - 180 - 50 - 50 + 30 = 750
finalScore = (int)(750 * 2.0) = 1500
Theoretical Maximum Scores
| Difficulty | Perfect Turns | Under Par | No Hints | Max Streak | Raw | Multiplied |
|---|---|---|---|---|---|---|
| EASY | 8 | Yes (+100) | Yes (+100) | 8 (+80) | 1780 | 1780 |
| MEDIUM | 18 | Yes (+100) | Yes (+100) | 18 (+180) | 1880 | 2820 |
| HARD | 32 | Yes (+100) | Yes (+100) | 32 (+320) | 2020 | 4040 |
Theoretical Minimum Scores
The floor is 0 (enforced by Math.max(0, score)). In practice, extreme values:
| Difficulty | Many Extra Turns | Over Par Time | All Hints Used | Raw | Final |
|---|---|---|---|---|---|
| EASY (50 turns, 300s, 3 hints) | -420 | -240 | -150 | 190 | 190 |
| MEDIUM (80 turns, 239s, 2 hints) | -620 | -119 | -100 | 161 | 241 |
| HARD (100 turns, 299s, 1 hint) | -680 | -149 | -50 | 121 | 242 |
Achievement System
Achievement Model
Source: MemorySnapAchievement (llitd.model.memorysnap.MemorySnapAchievement)
DB table: memory_snap.achievement
| Field | Type | Purpose |
|---|---|---|
achievementId |
int (PK) | Auto-increment ID |
achievementKey |
String (unique) | Programmatic key (e.g., "first_win") |
achievementName |
String | Display name |
achievementDescription |
String | Description text |
iconEmoji |
String | Achievement icon emoji |
requirementType |
String | One of 6 enum values (see below) |
requirementValue |
int | Target value to unlock |
requirementBoardSize |
Integer (nullable) | Specific board size, or null for any |
points |
int | Points awarded on unlock |
isActive |
boolean | Whether achievement is available |
Requirement Types
Defined as inner enum MemorySnapAchievement.RequirementType:
| Type | Description | Checked Against |
|---|---|---|
GAMES_WON |
Win N total games | account.totalGamesWon |
PERFECT_GAME |
Achieve a perfect game (min turns) | session.turnsTaken == session.totalPairs |
STREAK |
Reach N consecutive in-game matches | session.longestStreak |
SPEED |
Complete under N seconds | session.timeElapsedSeconds |
MATCHES |
Find N total matches lifetime | account.totalMatchesFound |
THEME_COMPLETE |
Complete all themes | Not implemented in code |
Board size scoping: appliesToBoardSize(int boardSize) returns true if requirementBoardSize == null (any board) or matches the specific board size.
Achievement Progress Tracking
Source: MemorySnapAccountAchievement (llitd.model.memorysnap.MemorySnapAccountAchievement)
DB table: memory_snap.account_achievement
| Field | Type | Purpose |
|---|---|---|
accountId |
Long (PK, FK) | Player reference |
achievementId |
int (PK, FK) | Achievement reference |
progress |
int (default 0) | Current progress toward goal |
unlocked |
boolean (default false) | Whether achieved |
unlockedAt |
LocalDateTime | When unlocked (null if not) |
Progress percentage: getProgressPercent() = min(100, (progress * 100) / requirementValue). Returns 100 if already unlocked.
Unlock logic: unlock() sets unlocked=true, unlockedAt=now(), progress=requirementValue.
Progress update: updateProgress(newProgress) only updates if NOT already unlocked.
Achievement DB Operations
The mapper supports:
- Upsert:
INSERT ... ON DUPLICATE KEY UPDATEfor progress updates - Unlock: Separate
unlockAchievementthat usesCOALESCE(unlocked_at, CURRENT_TIMESTAMP)to preserve original unlock time - Query: Get all achievements with LEFT JOIN to player progress, ordered by unlocked status then points
Achievement Processing
is called from completeGame() after score calculation. It handles all 6 requirement types via switch expression:
| Requirement Type | Progress Source | Example |
|---|---|---|
GAMES_WON |
account.totalGamesWon |
"Memory Awakened" (1 win) |
MATCHES |
account.totalMatchesFound |
Lifetime match milestones |
PERFECT_GAME |
session.turnsTaken == session.totalPairs |
"Flawless Memory" |
SPEED |
session.timeElapsedSeconds |
"Lightning Recall" (under 30s) |
STREAK |
session.longestStreak |
"On Fire" (10+ streak) |
THEME_COMPLETE |
Checked via theme completion query | "World Traveler" |
Progress is upserted (INSERT ON DUPLICATE KEY UPDATE) and achievements unlock when progress meets requirementValue. Board size scoping via requirementBoardSize (null = any difficulty).
Leaderboard System
Entry Creation
From :
MemorySnapLeaderboardEntry entry = MemorySnapLeaderboardEntry.fromSession(session);
leaderboardMapper.insertEntry(entry);
fromSession() creates an entry with:
accountId,boardSize,difficulty,score,turnsTakentimeSeconds=session.getTimeElapsedSeconds()theme=session.getTheme()achievedAt=LocalDateTime.now()
Every completed game creates a leaderboard entry. There is no filtering for minimum score, no deduplication, no "personal best only" logic at insert time. A player who completes 100 games will have 100 leaderboard entries.
Entry Schema
DB table: memory_snap.leaderboard
| Column | Type | Purpose |
|---|---|---|
leaderboard_id |
Long (PK, auto) | Entry ID |
account_id |
Long (FK) | Player reference |
board_size |
int | 4, 6, or 8 |
difficulty |
String (enum) | EASY, MEDIUM, HARD |
score |
int | Final score |
turns_taken |
int | Turns used |
time_seconds |
int | Time elapsed |
theme |
String | Theme played |
achieved_at |
LocalDateTime | Auto-set by DB default |
Ranking Logic
Sort order (from SQL):
ORDER BY lb.score DESC, lb.time_seconds ASC
Primary: highest score first. Tiebreaker: fastest time first.
Scope: Leaderboards are filtered by BOTH board_size AND difficulty. Since board size is determined by difficulty, this effectively creates 3 leaderboards (EASY/4x4, MEDIUM/6x6, HARD/8x8).
Player Rank Calculation
SELECT COUNT(*) + 1 AS player_rank
FROM memory_snap.leaderboard
WHERE board_size = #{boardSize}
AND difficulty = #{difficulty}
AND score > (
SELECT MAX(score) FROM memory_snap.leaderboard
WHERE account_id = #{accountId}
AND board_size = #{boardSize}
AND difficulty = #{difficulty}
)
This counts how many entries have a higher score than the player's BEST score, then adds 1. This is a dense rank based on the player's personal best within that difficulty.
Leaderboard Display
The displays top 10 entries:
List<MemorySnapLeaderboardEntry> entries = memorySnapService.getTopScores(boardSize, difficulty, 10);
Display format per entry:
{rank}. {displayName}: {score} points ({turnsTaken} turns, {timeSeconds}s)
Also shows the player's personal best at the bottom:
Your best: {bestScore} points.
Leaderboard Maintenance
-- deleteOldEntries: keeps only top N entries globally
DELETE FROM memory_snap.leaderboard
WHERE leaderboard_id NOT IN (
SELECT leaderboard_id FROM (
SELECT leaderboard_id FROM memory_snap.leaderboard
ORDER BY score DESC LIMIT #{keepTop}
) AS top_entries
)
Cleanup is now partitioned by board_size and difficulty — deleteOldEntries(boardSize, difficulty, keepTop) keeps the top N entries per difficulty, preventing HARD's 2.0x multiplier scores from wiping EASY/MEDIUM leaderboards.
Retry Configuration
All write operations on the leaderboard mapper use:
@Retryable(retryFor = .class,
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 3, maxDelay = 1000))
Retries on deadlock: 100ms, 300ms, 900ms (capped at 1000ms). Max 3 attempts.
Personal Best Tracking
Account-Level Best Scores
MemorySnapAccount stores 3 separate personal best scores:
| Field | Board Size | Difficulty |
|---|---|---|
bestScore4x4 |
4 | EASY |
bestScore6x6 |
6 | MEDIUM |
bestScore8x8 |
8 | HARD |
All are Integer (nullable) -- null means no completed game for that difficulty.
Update Logic
public boolean updateBestScore(int boardSize, int score) {
switch (boardSize) {
case 4:
if (bestScore4x4 == null || score > bestScore4x4) {
bestScore4x4 = score;
return true; // New personal best
}
break;
// ... same for 6 and 8
}
return false; // Not a new best
}
Called from :
account.updateBestScore(session.getBoardSize(), finalScore);
accountMapper.updateAccountStats(account);
The updateBestScore return value is used by completeGame() to display a "New Personal Best!" notification in the game completion response.
Account Statistics
Statistics Updated on Game Completion
From MemorySnapAccount.recordGameCompletion(won, matchesFound, streak):
totalGamesPlayed++; // Incremented on every completion call
if (won) {
totalGamesWon++; // Always true when called from completeGame()
currentStreak++; // Win streak increments
if (currentStreak > longestStreak) {
longestStreak = currentStreak;
}
} else {
currentStreak = 0; // Never reached (see note below)
}
totalMatchesFound += matchesFound;
Win rate calculation:
public double getWinRate() {
if (totalGamesPlayed == 0) return 0.0;
return (double) totalGamesWon / totalGamesPlayed * 100.0;
}
recordGameCompletion is called with won=true from completeGame() and won=false from quitGame(). Abandoned games increment totalGamesPlayed, reset currentStreak to 0, and do NOT increment totalGamesWon. Win rate reflects actual completion rate.
Statistics Updated on Abandon
When a game is abandoned ():
session.abandonGame();
sessionMapper.abandonSession(session.getSessionId());
account.recordGameCompletion(false, session.getPairsFound(), 0);
accountMapper.updateAccountStats(account);
Abandon records a loss: totalGamesPlayed++, currentStreak = 0, totalGamesWon unchanged.
REST API Score Endpoints
Source: ()
Base path: /api/memorysnap
| Endpoint | Method | Returns Score Data | Notes |
|---|---|---|---|
/start |
POST | MemorySnapGameStatus with currentScore=0 |
Initial status |
/flip |
POST | MemorySnapFlipResult with score field |
Score only set on game completion |
/status |
GET | MemorySnapGameStatus with currentScore and finalScore |
finalScore null until complete |
/hint |
POST | MemorySnapFlipResult (no score) |
Just success/failure message |
/leaderboard |
GET | List<MemorySnapLeaderboardEntry> |
Defaults: difficulty=EASY, limit=10 (board size derived from difficulty in service layer) |
/quit |
POST | Map<String,String> with message |
No score data |