Skip to content

Latest commit

 

History

History
200 lines (148 loc) · 5.83 KB

File metadata and controls

200 lines (148 loc) · 5.83 KB

Scoring Model

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.


Definitions

  • 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}} $$


Goals (intended behavior)

  • 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.

Formula

1) Speed score (for everyone; caps at 50k when 100/100)

Let $k$ control steepness (default 2):

$$ f(r) = \frac{r^{k}}{4 + r^{k}} \qquad\Rightarrow\qquad S_{\text{speed}} = \text{speedCap} \cdot f(r) $$

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.

2) Route bonus (only helps when you take fewer steps than optimal)

Let $p$ control sharpness (default 1.5) and $d$ control damping (default 3):

$$ B(e) = 1 + \frac{(e-1)^{p}}{d + (e-1)^{p}} \quad\text{with}\quad e=\frac{\text{optimal}}{\text{steps}} $$

  • At parity ($e = 1$): $B(1) = 1$ (no bonus).
  • As $e \to \infty$: $B(e) \to 2$ (never reaches it).

3) Final score (capped)

$$ \boxed{ \text{Score} = \min!\Big( \text{totalCap},; \underbrace{\text{speedCap} \cdot \frac{r^{k}}{4 + r^{k}}}_{\text{speed score}} \times \underbrace{\Big(1 + \frac{(e-1)^{p}}{d + (e-1)^{p}}\Big)}_{\text{route bonus}} \Big) } $$

Default caps and knobs

  • parMsPerStep = 250
  • speedCap = 50_000
  • totalCap = 100_000
  • k = 2
  • p = 1.5
  • d = 3

Properties & Intuition

  • 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.

Example outcomes (with defaults)

  • 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.)


Reference pseudocode

// 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))

Implementation

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));
}