Skip to main content

Scoring

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:

  1. Calls board.findUnmatchedPairCoordinates() to locate a matching pair by itemId
  2. Returns the card identity plus both coordinates (e.g., "Hint: 🦁 Lion is at A1 and C3!")
  3. Increments hintsUsed and deducts -50 from final score per hint
  4. 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 UPDATE for progress updates
  • Unlock: Separate unlockAchievement that uses COALESCE(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, turnsTaken
  • timeSeconds = 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