DEV Community

Scout_Bowie
Scout_Bowie

Posted on AI-assisted

How I built a 100% client-side Monte Carlo lineup simulator with zero backend using the Sleeper API

The Flaw of Static Fantasy Projections

Most fantasy platforms evaluate lineups using a single deterministic point estimate (e.g., "13.4 projected points").

In reality, athletic outcomes follow asymmetric distributions with high variance. A steady possession slot receiver with a tight 10–12 point distribution has the same median average as a deep-threat wide receiver who either posts 4 points or 26 points.

Deciding which player to start depends on matchup context:

  • As a heavy underdog: You want maximum variance and high ceiling to force an upset.
  • As a heavy favorite: You want minimum variance and high floor to protect your lead.

To solve this for my own leagues, I built Scout Bowie Analytics—a client-side decision suite and Monte Carlo matchup simulator built on Sleeper's public API.

Here is how the architecture and probabilistic engine work under the hood.


1. Zero-Backend Architecture via Sleeper REST API

Instead of hosting an API server, database, or authentication service, the entire application runs in the user's browser and deploys statically to GitHub Pages.

Sleeper provides public read-only REST endpoints that do not require OAuth credentials or API keys for read access.

async function fetchLeagueRosters(leagueId) {
  const [rostersRes, usersRes, leagueRes] = await Promise.all([
    fetch(`[https://api.sleeper.app/v1/league/$](https://api.sleeper.app/v1/league/$){leagueId}/rosters`),
    fetch(`[https://api.sleeper.app/v1/league/$](https://api.sleeper.app/v1/league/$){leagueId}/users`),
    fetch(`[https://api.sleeper.app/v1/league/$](https://api.sleeper.app/v1/league/$){leagueId}`)
  ]);

  const rosters = await rostersRes.json();
  const users = await usersRes.json();
  const league = await leagueRes.json();

  return { rosters, users, league };
}
Enter fullscreen mode Exit fullscreen mode

By decoupling storage entirely:

  • Zero Infrastructure Overhead: $0 monthly hosting costs via GitHub Pages.
  • Zero Friction: Users drop in their Sleeper League ID—no logins, passwords, or data collection.
  • Client-Side Caching: Static player metadata (~5MB JSON) is cached in IndexedDB and refreshed daily.

2. The Simulation Engine: Monte Carlo with Log-Normal Distributions

Player fantasy scoring is right-skewed—scores cannot drop below zero, but ceilings have long positive tails. We model each player's scoring output using a shifted log-normal distribution parameterized by projected median, positional variance, and environmental weights (Vegas implied totals, severe weather).

// Sample scoring simulation using Box-Muller transform for log-normal distribution
function samplePlayerScore(projection, floor, ceiling, varianceMultiplier = 0.35) {
  const u1 = Math.random();
  const u2 = Math.random();

  // Standard normal deviate
  const z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2);

  const mu = Math.log(Math.max(projection, 0.5));
  const sigma = varianceMultiplier;

  // Log-normal sample bounded by dynamic floor/ceiling
  let sampledScore = Math.exp(mu + sigma * z0);
  return Math.min(Math.max(sampledScore, floor), ceiling * 1.25);
}
Enter fullscreen mode Exit fullscreen mode

For every start/sit permutation, the engine simulates 10,000 full matchups:

function runMatchupSimulation(myLineup, opponentLineup, iterations = 10000) {
  let wins = 0;
  let ties = 0;

  for (let i = 0; i < iterations; i++) {
    const myTotal = myLineup.reduce((sum, p) => sum + samplePlayerScore(p.proj, p.floor, p.ceil), 0);
    const oppTotal = opponentLineup.reduce((sum, p) => sum + samplePlayerScore(p.proj, p.floor, p.ceil), 0);

    if (myTotal > oppTotal) wins++;
    else if (myTotal === oppTotal) ties++;
  }

  const winProbability = (wins + ties * 0.5) / iterations;
  return { winProbability };
}
Enter fullscreen mode Exit fullscreen mode

3. Offloading Computation to Web Workers

Executing 10,000 iterations across multiple roster combinations involves millions of random sampling operations. Running this on the main browser thread causes noticeable UI stutter.

To keep the interface responsive at 60 FPS, the simulation loops are isolated within a dedicated Web Worker:

// worker.js
self.onmessage = function (e) {
  const { userRoster, opponentRoster, benchCandidates, iterations } = e.data;

  const simulationResults = benchCandidates.map(candidate => {
    const testLineup = swapFlexPlayer(userRoster, candidate);
    return {
      player: candidate,
      results: runMatchupSimulation(testLineup, opponentRoster, iterations)
    };
  });

  self.postMessage(simulationResults);
};
Enter fullscreen mode Exit fullscreen mode

4. Dynamic VORP & Survival Curves (Draft Suite)

In the draft companion module, static ADP is replaced by dynamic Value Over Replacement Player (VORP) calculated against the specific league's scoring rules and starter slots:

// Dynamic VORP calculation
const replacementIndex = totalTeams * startersPerPosition[position];
const replacementBaseline = sortedRankings[position][replacementIndex]?.projectedPoints || 0;

const vorp = Math.max(0, player.projectedPoints - replacementBaseline);
Enter fullscreen mode Exit fullscreen mode

To prevent reaching on positional tiers, reach probability is estimated using a survival function based on draft pick distance and position ADP standard deviation:

// Reach probability estimation
function calculateReachProbability(currentPick, nextTurnPick, playerAdp, adpStdDev = 6.5) {
  const picksAway = nextTurnPick - currentPick;
  const zScore = (playerAdp - nextTurnPick) / adpStdDev;

  // Standard Normal CDF approximation
  const reachProbability = 1 / (1 + Math.exp(-1.702 * zScore));
  return Math.max(0, Math.min(1, reachProbability));
}
Enter fullscreen mode Exit fullscreen mode

Try It & Source Code

Feedback on the statistical modeling or client-side architecture is welcome in the comments!

Top comments (0)