diff --git a/src/components/frontend/minigames/opord/OpordArena.tsx b/src/components/frontend/minigames/opord/OpordArena.tsx
index 4aca4b9..d153404 100644
--- a/src/components/frontend/minigames/opord/OpordArena.tsx
+++ b/src/components/frontend/minigames/opord/OpordArena.tsx
@@ -150,8 +150,9 @@ export function OpordArena({
Debrief
A perfect run inside {OPORD_DIFFICULTIES[difficulty].parTimeMs / 1000}s earns a{" "}
- {OPORD_DIFFICULTIES[difficulty].perfectBonus}-point bonus. Each tier tracks its own
- best score.
+ {OPORD_DIFFICULTIES[difficulty].perfectBonus}-point bonus. Finishing under the par
+ time also earns a speed bonus that scales with how fast you finish and how many
+ answers you got right. Each tier tracks its own best score.
diff --git a/src/lib/minigames/opord-recall.ts b/src/lib/minigames/opord-recall.ts
index 6338cbe..60b84ca 100644
--- a/src/lib/minigames/opord-recall.ts
+++ b/src/lib/minigames/opord-recall.ts
@@ -26,6 +26,12 @@ export interface OpordTierConfig {
readonly parTimeMs: number;
/** Bonus for a perfect run inside the par time. */
readonly perfectBonus: number;
+ /**
+ * Points pool for the time bonus: a run finishing under the par time earns
+ * this scaled by the fraction of par time saved and the fraction of
+ * questions answered correctly, so speed pays only alongside accuracy.
+ */
+ readonly maxSpeedBonus: number;
}
/** Grace added to the quiz limit for network/rounding before the server rejects. */
@@ -50,6 +56,7 @@ export const OPORD_DIFFICULTIES: Record = {
correctPoints: 100,
parTimeMs: 100_000,
perfectBonus: 150,
+ maxSpeedBonus: 150,
},
veteran: {
studyHintSeconds: 25,
@@ -58,6 +65,7 @@ export const OPORD_DIFFICULTIES: Record = {
correctPoints: 150,
parTimeMs: 150_000,
perfectBonus: 300,
+ maxSpeedBonus: 250,
},
elite: {
// Extra clock headroom: elite relational questions include time arithmetic.
@@ -67,6 +75,7 @@ export const OPORD_DIFFICULTIES: Record = {
correctPoints: 200,
parTimeMs: 200_000,
perfectBonus: 500,
+ maxSpeedBonus: 400,
},
};
@@ -103,7 +112,15 @@ export function calculateOpordScore(
): number {
const tier = OPORD_DIFFICULTIES[difficulty];
const perfect = correct >= tier.questionCount && timeMs <= tier.parTimeMs;
- return Math.max(0, correct * tier.correctPoints + (perfect ? tier.perfectBonus : 0));
+ const speedBonus =
+ timeMs < tier.parTimeMs
+ ? Math.round(
+ ((tier.parTimeMs - timeMs) / tier.parTimeMs) *
+ tier.maxSpeedBonus *
+ (correct / tier.questionCount),
+ )
+ : 0;
+ return Math.max(0, correct * tier.correctPoints + speedBonus + (perfect ? tier.perfectBonus : 0));
}
export function calculateOpordXp(score: number, isNewTierBest: boolean): number {