This document describes the scoring logic used by the game. It’s designed to be fair for Vim-style speedrunners and intuitive to reason about.
- optimalSteps (
optimal) — length of the optimal path in steps (e.g., 100). - playerSteps (
steps) — total steps the player actually traversed (not keystrokes). - logicMs — elapsed logic time in milliseconds (animation-agnostic; measured between accepted motions).
- parMsPerStep — par time per optimal step in ms (default 250ms).
Derived:
-
Par time:
$$ T_{\text{par}} = \text{optimal} \times \text{parMsPerStep} $$ -
Speed ratio (>!1 means faster than par):
$$ r = \frac{T_{\text{par}}}{\text{logicMs}} $$ -
Route efficiency (=1 at parity; >1 if you used fewer steps than optimal):
$$ e = \frac{\text{optimal}}{\text{steps}} $$
- A 100/100 run at par time scores 10,000.
- A 100/100 run can grow exponentially with speed and asymptotically caps at 50,000.
- Runs with fewer-than-optimal steps can exceed 50k via a route bonus and asymptotically approach 100,000 (never reach it).
- Fewer steps alone shouldn’t beat the best fast 100/100 runs; you must also be fast.
Let
With speedCap = 50{,}000 and k = 2:
- At par (
$r=1$ ): $ f(1)=\tfrac{1}{5}=0.2 \Rightarrow S=10{,}000 $. - As $ r \to \infty $: $ S \to 50{,}000 $.
The constant 4 in the denominator is chosen so that
$f(1)=0.2$ , giving 10k at par with a 50k cap.
Let
- At parity (
$e = 1$ ):$B(1) = 1$ (no bonus). - As
$e \to \infty$ :$B(e) \to 2$ (never reaches it).
Default caps and knobs
parMsPerStep = 250speedCap = 50_000totalCap = 100_000k = 2p = 1.5d = 3
-
100/100 at par →
$r=1, e=1$ →$S=50k \cdot \tfrac{1}{5} \cdot 1 = \mathbf{10{,}000}$ . -
100/100 faster →
$e=1, r\uparrow$ → score grows “exponentially” and asymptotically approaches 50k. -
Fewer steps than optimal →
$e>1$ → multiplicative route bonus$B(e)\in[1,2)$ → asymptote approaches 100k (but never reaches). -
Fairness: If you’re not faster than par, route bonus alone is bounded; e.g., at
$r=1$ , even large$e$ yields at most$50k \cdot 0.2 \cdot <2 \approx <20k$ — still below top-tier fast 100/100 runs.
-
100/100 in 25s (par):
$r=1, e=1 \Rightarrow \text{Score}=10{,}000$ . -
100/100 much faster (huge
$r$ ):
$e=1 \Rightarrow \text{Score}\to 50{,}000$ . -
100 optimal in 50 steps (
$e=2$ ) and very fast:
Route bonus$B(2) \approx 1 + \frac{1^{1.5}}{3+1^{1.5}} = 1.25$ .
As$r \to \infty$ : score$\to 50{,}000 \times 1.25 = 62{,}500$ .
(Tune$p,d$ for stronger/weaker bonuses; absolute cap is 100k.)
// inputs
optimalSteps
playerSteps // actual traversed steps
logicMs // animation-agnostic time in ms
// params (defaults)
parMsPerStep = 250
speedCap = 50_000
totalCap = 100_000
k = 2
p = 1.5
d = 3
// derived
Tpar = optimalSteps * parMsPerStep
r = Tpar / logicMs // speed ratio
e = optimalSteps / playerSteps // route efficiency
// speed score (10k at par, asymptote 50k)
speedFactor = (r**k) / (4 + r**k)
speedScore = speedCap * speedFactor
// route bonus (1..2)
eDelta = Math.max(0, e - 1)
routeBonus = 1 + ( (eDelta**p) / (d + eDelta**p) )
// final
score = Math.min(totalCap, speedScore * routeBonus)
score = Math.max(0, Math.round(score))export function computeScore({
optimalSteps,
playerSteps,
logicMs,
parMsPerStep = 250,
k = 2, // speed curve steepness
p = 1.5, // route bonus sharpness
d = 3, // route bonus damping
speedCap = 50_000,
totalCap = 100_000,
}: ScoreParams): number {
if (!Number.isFinite(optimalSteps) || optimalSteps <= 0) return 0;
if (!Number.isFinite(playerSteps) || playerSteps <= 0) return 0;
if (!Number.isFinite(logicMs) || logicMs <= 0) return 0;
// --- Ratios
const tParMs = optimalSteps * parMsPerStep; // par time in ms
const r = tParMs / logicMs; // speed ratio (1 at par, >1 faster)
const e = optimalSteps / playerSteps; // route efficiency (1 at parity, >1 fewer steps)
// --- Speed score (caps at speedCap; equals 10k at par with defaults)
// f(r) = r^k / (4 + r^k) -> f(1) = 1/5 = 0.2 => speedCap * 0.2 = 10k
const rPow = Math.pow(Math.max(0, r), k);
const speedFactor = rPow / (4 + rPow);
const speedScore = speedCap * speedFactor;
// --- Route bonus (multiplier in [1, <2), approaches 2 as e -> ∞)
// B(e) = 1 + ( (e-1)^p / (d + (e-1)^p) )
const eDelta = Math.max(0, e - 1);
const ePow = Math.pow(eDelta, p);
const routeBonus = 1 + (ePow / (d + ePow)); // 1.0 at e=1, -> 2.0 asymptotically
// --- Combined
let score = speedScore * routeBonus;
// Hard cap at totalCap (e.g., 100k asymptote)
score = Math.min(score, totalCap);
// Round to integer, never negative
return Math.max(0, Math.round(score));
}