Island of Eras

Episode Simulation Algorithm Reference

Source: src/lib/simulation/ Date: 2026-05-20 Feature: Document Simulation Algorithm (specs/074-document-simulation-algorithm)

Overview

The episode simulation engine runs a single episode of a Survivor season. It takes the current game state (active players, their stats, manager directives, alliance history, idol pool) and produces a complete episode result: who won immunity, who was voted out, what idols were played, how alliances shifted, and how each manager ranked based on how long their players survived.

The engine is deterministic — given the same seed and state, it always produces the same result. This is achieved via a mulberry32 pseudo-random number generator seeded from a string.


Architecture & File Map

src/lib/simulation/
├── engine.ts          Orchestrator: runs the full episode pipeline
├── rewards.ts          Reward challenge phase: reward selection, tribe contest, effect application
├── tribal.ts          Tribal council voting and elimination logic
├── challenge.ts        Immunity challenge winner selection
├── alliances.ts        Alliance formation and dissolution
├── advantages.ts       Hidden immunity idol finding
├── directives.ts       Manager directive → simulation modifier mapping
├── scoring.ts          Standings calculation
├── ids.ts              Idol pool constants and state management
├── probabilities.ts    All probability formulas (challenge, idol find, idol play)
└── prng.ts             Deterministic pseudo-random number generator

src/services/simulation.service.ts  Prepares state from DB, calls engine, persists results
src/types/simulation.ts             All TypeScript type definitions
FileLinesPurpose
engine.ts534Pipeline orchestrator: simulateEpisode(state) — the main entry point
rewards.ts172Reward challenge: selectReward() menu, resolveRewardChallenge(), effect applicator
tribal.ts233Tribal council voting, idol plays, tie-breaking, vote tallying
challenge.ts41Immunity challenge winner selection via weighted random pick
alliances.ts65Alliance formation (weighted sampling) and dissolution (social-based break)
advantages.ts33Idol finding with per-player probability and concurrent limit
directives.ts46Manager directive types → simulation modifier mapping
scoring.ts60Manager standings based on player elimination order
ids.ts47Idol pool constants and computePoolUpdate() state tracking
probabilities.ts46computeChallengeWinRate(), computeIdolFindChance(), computeIdolPlayProbability()
prng.ts39Deterministic PRNG (mulberry32), weightedPick(), shuffleArray()

Core Data Structures

SimulationState (Input)

The input to simulateEpisode(), built by the simulation service from database records.

FieldTypeDescription
players[]ArrayActive roster entries with composite stats (historical + simulated)
episodeNumbernumberCurrent episode (1-based)
totalEpisodesnumberTotal episodes in the season
directives[]ArrayManager-issued directives for this episode
seedstringDeterministic seed for the PRNG
existingAlliancesstring[][]Alliances formed in previous episodes
episodeAlliancesHistorystring[][][]Per-episode alliance snapshots (for connection graph)
episodeVoteHistory{voterId, targetId}[][]Per-episode vote records (for foe graph)
idolPool{available, everIntroduced}Current idol pool state

Player fields within SimulationState

AttributeDescription
challengeWinRateBase challenge win probability (0–1)
socialRatingSocial game rating (0–1)
strategicRatingStrategic game rating (0–1)
idolCountIdols currently held
historicalIdolCountIdols found in real Survivor history
simulatedIdolFindsIdols found so far in this simulation
simulatedIdolPlaysIdols played so far in this simulation
simulatedChallengeWinsChallenge wins so far in this simulation
placementHistorical placement (1 = winner)
activeWhether this player is still in the game

EpisodeResults (Output)

The complete output containing everything needed for UI rendering and DB persistence.

FieldTypeDescription
challengeChallengeResultWho won immunity, challenge type, description
tribalCouncilTribalCouncilResultWho was voted out, vote breakdown, idol plays
alliances{formed, broken}New alliances formed, existing alliances broken
currentAlliancesstring[][]Alliances active after this episode
connectionsConnectionEntry[]Alliance and foe connection percentages (for force graph)
idolFindsArrayPlayers who found idols this episode
idolPoolUpdateIdolPoolUpdateIdol pool state after this episode
finalFourFinalFourResultPresent only on the penultimate 4-player episode

Sub-types

ChallengeResult: winnerId, winnerName, type (endurance/puzzle/obstacle/balance/memory), description, participants[]

TribalCouncilResult: votedOut, votedOutName, votedOutSeasonNumber, voteBreakdown[] (voter→target pairs with names and season numbers), voteRationales[] (parallel per-vote reason flags), story (post-simulation voting narrative), idolPlays[] (player, protected player, nullified vote count), advantagePlays[], and councils[] (pre-merge per-tribe summary, each with its own story)

IdolPoolState: {available: number, everIntroduced: number}

ConnectionEntry: playerId, playerName, playerSeasonNumber, connections[] (each a DirectedConnection with playerId, playerName, playerSeasonNumber, strength: number (0–100), type: 'alliance' | 'foe')


10-Phase Episode Simulation Pipeline

The simulateEpisode() function in engine.ts runs these phases in strict order. Each phase builds on the results of the previous ones.

#PhaseSourceWhat It Produces
1Apply directivesdirectives.tsChallenge multipliers, idol find bonuses, vote targets
2Resolve reward challengerewards.tsReward type, winner (tribe/individual), effect modifiers (pre-merge & post-merge)
3Resolve challengechallenge.tsImmunity winner and challenge type
4Resolve alliancesalliances.tsFormed alliances, broken alliances, current active alliances
5Find idolsadvantages.tsList of player IDs who found idols this episode
6Compute idol play probabilitiesengine.ts:258-296Per-player probability they will play an idol at tribal
7Resolve tribal counciltribal.tsVote breakdown, idol plays, eliminated player (replaced by the safe-pick + fire-making flow on the final-four penultimate episode)
8Calculate standingsscoring.tsManager standings based on player elimination order
9Update idol poolids.tsPost-episode idol pool state
10Compute connectionsengine.ts:28-91Alliance and foe connection percentages for force graph

Episode Phase Dispatch (Merge Trigger)

Before the pipeline runs, the simulation service determines the episode phase (pre_merge, merge, or post_merge) using a rising-chance merge trigger. This replaces the previous hard-threshold check with a probabilistic system.

Merge Configuration

At season creation, the creator sets:

  • Merge target players (default 13): The desired player count when the merge should happen.
  • Merge deviation (default 2): Allowed variation from the target, defining the merge window.

These produce a merge window: [mergeTarget - mergeDeviation, mergeTarget + mergeDeviation] (e.g., 11–15 players).

Merge Threshold Roll

At season creation (or first pre-merge episode for legacy seasons), a concrete merge threshold is rolled uniformly from the range [mergeTarget - mergeDeviation, mergeTarget + mergeDeviation]. This value is stored on the Season record and is deterministic given the same season seed.

Rising-Chance Probability

At the start of each pre-merge episode, the engine evaluates:

ConditionMerge ProbabilityOutcome
activeCount > mergeTarget + mergeDeviation0%Stay pre_merge
activeCount within window [target ± deviation]Linear from 0%→100% as player count decreasesRoll probability check
activeCount ≤ mergeTarget - mergeDeviation100% (immediate)Force merge

Within the window, the probability formula is:

probability = (mergeTarget + mergeDeviation - activeCount) / (2 * mergeDeviation)

Force Conditions

The merge fires immediately (no roll) when:

  1. Active player count reaches or falls below mergeTarget - mergeDeviation (lower window bound).
  2. The episode is the penultimate episode (totalEpisodes - 1).
  3. The episode phase is already merge or post_merge (merge has already occurred).

Merge Episode Behavior

When an episode's phase is merge:

  • Challenge: Runs in individual format (tribes dissolve).
  • Reward Challenge: Skipped (merge is a transitional celebration episode).
  • Tribal Council: Skipped entirely — no vote-out, no vote breakdown, no idol plays at tribal.
  • Idol Finds: Processed normally.
  • Alliance Changes: Processed normally.
  • Standings: Manager rankings updated based on player elimination order.

Post-Merge Transition

After the merge episode fires, all subsequent episodes are post_merge with individual challenges and all-player tribal councils. No further merge checks are performed.

Jury Eligibility

  • Players eliminated during pre-merge episodes are not jury members.
  • Players eliminated during post-merge episodes are jury members.
  • Jury members vote for the winner during the final tribal council (finale resolution).

campaign_jury Directive

The campaign_jury directive is only effective during post-merge episodes. It adds +2 to the target player's jury campaign bonus, which is accumulated across episodes and added to the player's jury vote tally during the finale. During pre-merge episodes, campaign_jury directives are stored but have no effect.


Phase 1: Manager Directives

Source: directives.ts:applyDirectives()

Before each episode simulates, managers can issue directives to their roster players. These directives shift probability weights in the simulation engine. Unmanaged players act on default AI behavior with no modifiers.

Directive Types and Effects

DirectiveEffect on PlayerStacking
throw_challengeChallenge win rate multiplied by 0.5 (halved)No (single multiplier per player)
search_idolIdol find chance increased by +0.18 per directiveYes (multiple directives add more bonus)
target_voteVoter strongly biased to target a specific player at tribal (vote weight ×3.0)Each voter gets one target; multiple targets for same player affect threat level
form_allianceForms forced alliances between the acting player and each selected ally (alliances persist through the episode)Yes (each selected ally forms one pairing)

How Directives Are Applied

The applyDirectives() function produces a DirectiveEffects object:

DirectiveEffects = {
  challengeWinRateMultiplier: Map<playerId, multiplier>   // default 1.0
  idolFindBonus: Map<playerId, bonus>                     // default 0
  voteTargets: Map<voterId, targetId>                     // who each voter is targeting
  alliancesToForm: string[][]                              // forced alliance pairs from form_alliance (one per selected ally)
}

Directive Effects in Tribal Council

Beyond the direct modifiers above, directives also influence tribal council through threat modifiers in the voting weight formula (see Phase 6):

  • +0.3 to the weight of any player who has been targeted by a directive (they are perceived as threats)
  • +0.1 to the weight of any player who issued a directive (they are seen as strategic players)
  • Players targeted by 2 or more directives are considered "heavily targeted" — this increases their probability of playing an idol

Phase 2: Reward Challenge

Source: rewards.ts

Before the immunity challenge in every episode that resolves a reward challenge, the engine randomly selects one reward from the phase-aware reward pool and determines a winner. The pool contains up to 5 types in total, but Letters from Home has special availability and the pool varies by phase (see Reward Selection below). The reward's buff is applied to the winner(s) for the remainder of the episode.

Mode: Tribe (Pre-Merge)

In pre-merge episodes, the reward challenge runs as a tribe-vs-tribe contest. The engine declares a winning tribe and applies the reward's buff to all members of that tribe.

See below for the tribe-level contest mechanics (scoring formula, sit-out resolution).

Mode: Individual (Merge / Post-Merge)

In merge and post-merge episodes, the reward challenge runs as an individual contest. All active players compete, and a single winner is chosen via weighted random selection. The reward's effect applies to that single player for the remainder of the episode.

Winner Selection:

noisyWeight = effectiveChallengeWeight * (0.8 + rng() * 0.4)
winner = weightedPick(players, player => player.noisyWeight, rng)

effectiveChallengeWeight is computed using computeChallengeWinRate() — the same function used for individual immunity challenges. The noise multiplier (0.8 + rng() * 0.4) is identical to the individual immunity noise formula, but the random rolls are independent, so the reward and immunity winners can be different players.

Important: Sit-outs do not apply post-merge — every active player competes. Winning a reward does not grant immunity — the winner still participates in the immunity challenge.

Individual Reward Effects (Merge / Post-Merge)

RewardEffect (winner only, this episode only)
Food+0.1 challenge weight multiplier on this episode's immunity
Idol clue+0.18 idol find chance for the winner
Letters from home-50% alliance break chance for alliances containing the winner

The individual pool always excludes Comfort items and Spy mission — these rewards are tribe-only and can no longer be awarded once tribes are merged.

Tribe Reward Effects (Pre-Merge)

These are the same 5 rewards applied tribe-wide:

RewardEffect (winning tribe only, this episode only)
Comfort items+0.05 social rating to all tribe members
Food+0.1 challenge weight multiplier on this episode's immunity challenge
Idol clue+0.18 idol find chance for one randomly chosen tribe member
Spy mission+0.1 strategic rating to all tribe members
Letters from home-50% to this tribe's alliance break chance this episode

Reward Menu (v1)

RewardEffect (winning tribe only, this episode only)
Comfort items+0.05 social rating to all tribe members
Food+0.1 challenge weight multiplier on this episode's immunity challenge
Idol clue+0.18 idol find chance for one randomly chosen tribe member
Spy mission+0.1 strategic rating to all tribe members
Letters from home-50% to this tribe's alliance break chance this episode

Reward Selection

One reward is selected uniformly at random from the phase-aware reward pool using the simulation's PRNG (getRewardPool(phase, lettersAvailable)). The pool is derived from the episode phase:

  • Pre-merge (tribe pool): comfort_items, food, idol_clue, spy_mission. Letters from Home is excluded. One of the 4 types is selected uniformly (floor(rng() * 4)).
  • Merge / Post-merge (individual pool): food, idol_clue. Tribe-only rewards (comfort_items, spy_mission) are excluded because no tribes exist after the merge.
  • Letters from Home: becomes available only when 6 or fewer players remain alive post-merge. On the first eligible episode (post-merge, 6 or fewer players, Letters from Home not yet awarded this season), Letters from Home is guaranteed and forced as the reward. For all other post-merge episodes, Letters from Home is excluded (it is awarded at most once per season). When it is eligible, the pool is ['letters_from_home'] regardless of phase.

In all cases, the remaining available rewards are selected uniformly at random, and the selected reward renders its individual description in the merge/post-merge UI (the tribe wording is only shown for pre-merge tribe challenges).

Tribe-vs-Tribe Contest

The reward challenge uses the same tribe-level scoring formula as the immunity challenge:

tribeScore = avgWeight * (0.8 + rng() * 0.4)

Where avgWeight is the average challenge weight of participating tribe members. Sit-out resolution follows the identical two-phase algorithm used by the immunity challenge (manager-selected sit-outs first, random fallback second, with the consecutive-sit-out rule applied per-challenge type).

The winning tribe is selected via weightedPick() using tribe scores as weights.

Effect Application

After the winning tribe is determined, applyRewardEffect() computes per-player modifiers for the current episode:

Effect FieldDefaultHow It's Used
challengeMultiplier1.0Multiplied into immunity challenge player weight computation
socialBonus0.0Added to socialRating for alliance and tribal council calculations
strategicBonus0.0Added to strategicRating for tribal council threat assessment
idolFindBonus0.0Added to idol find chance before the advantage resolution phase
allianceBreakMultiplier1.0Multiplied into per-player alliance break multiplier

Effects are applied before the immunity challenge phase computes player weights and before alliance/idol phases compute their probabilities, ensuring the reward can influence downstream outcomes.

Output

The reward challenge result is stored in EpisodeResults.rewardChallenge as a discriminated union:

Tribe mode (mode: 'tribe' — pre-merge):

FieldTypeDescription
mode'tribe'Discriminant for pre-merge tribe-vs-tribe rewards
rewardTypeRewardTypeThe selected reward (comfort_items, etc.)
winnerTribeIdstringID of the winning tribe
winnerTribeNamestringName of the winning tribe
tribeScoresTribeScore[]Per-tribe scores and participants
participantsRecord<string,string[]>tribeId → playerId[] map of post-sit-out participants
sitOutsSitOutRecord[]Players who sat out this reward challenge
targetPlayerIdstring | nullFor idol_clue: the randomly chosen recipient. null otherwise

Individual mode (mode: 'individual' — post-merge):

FieldTypeDescription
mode'individual'Discriminant for post-merge individual rewards
rewardTypeRewardTypeThe selected reward
winnerPlayerIdstringID of the winning player
winnerPlayerNamestringName of the winning player
participantsstring[]All active player IDs (no sit-outs post-merge)
playerWeightsRecord<string,number>Per-player challenge weights used for winner selection
targetPlayerIdstring | nullFor idol_clue: always equals winnerPlayerId. null otherwise

For merge episodes, rewardChallenge is null.

Effect Scoping

Reward effects are local variables within simulateEpisode() — computed fresh each episode and automatically discarded when the function returns. No effect data persists between episodes. Only the RewardChallengeResult record (what reward was awarded and to whom) is persisted on the EpisodeData row.


Phase 3: Challenge Resolution (Immunity)

Source: challenge.ts:resolveChallenge()

Challenge Type Selection

A challenge type is randomly picked from five options: endurance, puzzle, obstacle, balance, memory

Note: The challenge type is cosmetic — it does not affect the probability calculation. All types use the same weight formula.

Winner Selection

Each active player's effective challenge weight is computed:

effectiveWeight = challengeWinRate * (0.8 + rng() * 0.4)

Where challengeWinRate is pre-computed to include:

  1. Base stat: The player's inherent challenge win rate (from real Survivor history)
  2. Directive multiplier: If the player has a throw_challenge directive, multiply by 0.5
  3. Simulated win bonus: +0.04 per prior simulated challenge win, capped at +0.30 total

The winner is selected via weightedPick() — each player's probability is proportional to their effective weight.

The immunity winner cannot be voted against in tribal council, but can still vote.

Challenge Win Rate Formula

function computeChallengeWinRate(challengeWinRate, simulatedChallengeWins) {
  const simulatedBonus = Math.min(simulatedChallengeWins * 0.04, 0.3) // +0.04/win, capped +0.30
  return clamp(challengeWinRate + simulatedBonus, 0, 1)
}

Pre-Merge: Tribe-vs-Tribe Challenge

When Episode.phase === 'pre_merge', the challenge is a tribe-level competition. Each tribe fields a team, and the tribe with the highest average performance wins immunity.

Sit-Out Resolution

Before scoring, participant counts are equalized to the smallest tribe's active member count. Members from larger tribes are selected to sit out via a two-phase process:

  1. Manager selections: Players designated by managers in Episode.sitOuts are removed first, provided they did not sit out in the immediately preceding episode (no-consecutive-sit-out rule).
  2. Random fallback: Remaining slots are filled via shuffleArray() from the remaining eligible pool, excluding prior-episode sit-outs. If all eligible players sat out last episode, the constraint is relaxed.

Invalid manager selections (inactive players, wrong tribe, or prior sit-outs) are silently ignored and replaced.

Tribe Scoring

Each tribe's score is computed as:

tribeScore = avgWeight * (0.8 + rng() * 0.4)

Where:

  • avgWeight = sum of participating members' effective challengeWinRate / participant count
  • The noise multiplier (0.8 + rng() * 0.4) is rolled once per tribe (not per player)

Note: The per-player +0.04 simulated win bonus is included in each player's weight before averaging, matching the individual challenge formula.

The winning tribe is selected via weightedPick() using tribe scores as weights.

Output Extension

In pre-merge mode, ChallengeResult includes:

FieldDescription
winningTribeIdThe ID of the winning tribe
tribeScores[]Per-tribe results: tribeId, tribeName, score, participants (player IDs who competed)
sitOuts[]Per-tribe sit-out records: tribeId, playerIds (who sat out)

winnerId and winnerName are set to a representative player from the winning tribe for backward compatibility.

Post-Merge: Individual Challenge (unchanged)

When the episode is merge or post_merge, the challenge resolves as an individual competition matching the pre-existing behavior. The winningTribeId, tribeScores, and sitOuts fields are absent from the result.


Phase 4: Alliance Dynamics

Source: alliances.ts:resolveAlliances() + relationship-signals.ts

Alliance formation is event-driven: what happened in the game (who voted with or against whom, reward/tribe co-participation, challenge outcomes) moves the odds on top of social and strategic ratings. The engine also guarantees a baseline relationship so an episode with interacting players never renders an empty alliance graph.

Interaction Signals

For a pair of players (a, b) the simulator derives an affinity from data already present in the episode pipeline:

SignalSourceEffect
coVoteShareBallots for the same target across prior, non-nullified votes, divided by episodes both players appearPositive
oppositionShareBallots cast by one against the other, divided by shared episodesNegative
rewardBondBoth on the winning reward tribe (or the same individual reward group) this episodePositive
tribeBondBoth currently on the same tribePositive
pairAffinity(a, b) = clamp(
    0.50 * coVoteShare
  + 0.30 * rewardBond
  + 0.20 * tribeBond
  - 0.50 * oppositionShare,
  -1, 1)

Challenge outcomes are already represented through the clutch social bonus: the immunity winner and top performers receive +0.15 social for the episode.

Why prior votes? Alliances are resolved before the tribal council so allies avoid voting for each other. The current episode's own ballots therefore cannot be an input to its alliance formation; they are reflected immediately in the episode's foe connections (see below). Vote alignment from earlier episodes, plus this episode's challenge and reward events, drives formation.

Forming Alliances (Blocs)

Alliances are modelled as a small, unpredictable number of blocs rather than many tiny groups. Per eligible group — a tribe during the pre-merge phase, or all active players post-merge:

  1. The allied share of the group is 0.60 + rng() * 0.25, and roughly 25% of the time the whole group is allied, so a majority of players are in an alliance.
  2. That allied pool is divided among a random 1–4 blocs, chosen so the average bloc is about three members (each bloc 2–6); the remaining players are on the outs.
  3. A bloc is seeded by the highest-affinity pair, then grown to its target size by repeatedly adding the remaining player with the greatest summed affinity to the bloc (ties broken by combined selection weight, then player id).
selectionWeight = (socialRating * 0.7 + strategicRating * 0.3 + 0.01) * allianceFormationMultiplier

Pre-merge alliances remain strictly single-tribe and cross-tribe alliances are prohibited during the pre-merge phase.

Baseline Guarantee

An episode with interacting players never produces an empty relationship set:

  • Every eligible group — a tribe, or the pool of players with no tribe assignment — always produces at least one main bloc (unless every pair is already allied).
  • After tribal council, if the assembled connection set is still empty and two or more players are active, the engine emits one baseline edge — preferring a current alliance, otherwise a foe edge for the strongest-opposition pair.

Players missing from playerTribeMap (for example, a season whose rosters have no current tribe) are grouped together rather than dropped, so alliances still form when tribe data is absent or partial.

Strength Ramp Over the Season

Reported connection strengths scale with season progress so early episodes show weak, tentative relationships:

progressScale = clamp(0.25 + 0.75 * (episode - 1) / (totalEpisodes - 1), 0.25, 1.0)
strength = round(rawShare * 100 * progressScale)   # capped at 100

rawShare is the same shared-episode or vote-share ratio as before. A single episode-1 vote therefore shows a low-strength edge, while the same behavior sustained late in the season approaches 100. Applies to both alliance and foe strengths.

Foe Connections

Foe strength counts the current episode's non-nullified ballots as well as prior episodes, so episode 1 can show foes and alliance/foe strength no longer lags or is asymmetrical. Ballots nullified by an idol play are excluded, and strength is capped at 100.

Worked Example

Four post-merge players, all with socialRating = strategicRating = 0.5, no existing alliances, and these prior ballots:

  • Episode 1: p1 → p3, p2 → p3
  • Episode 2: p1 → p4, p2 → p4

Derived signals:

PaircoVotes / sharedopposition / sharedaffinity
p1–p22 / 20 / 20.5 * 1 = 0.50
p1–p30 / 11 / 1-0.5 * 1 = -0.50
p3–p40 / 00 / 00.00

p1–p2 is the seed pair. The four-player group rolls a single bloc (0.10) with a two-member size (0.00), so the episode forms [p1, p2]. Because currentAlliances contains an alliance between active players, the connection set includes an alliance edge p1 ↔ p2 (strength scaled by the season-progress ramp).

Breaking Existing Alliances

Each existing alliance has a chance to dissolve based on its members' average social rating:

breakChance = 0.1 * (1 - avgSocialRating) * minBreakMultiplier
Avg SocialBreak Chance
1.0 (perfect)0%
0.73%
0.55%
0.37%
0.010%

Lower social cohesion = more likely to fracture. Alliances of at least two members are eligible.

Defection & Episode-to-Episode Change

Alliances are not rebuilt from scratch each episode, but they do evolve:

  • Each surviving alliance's members are checked for defection (12% per member per episode). Defectors leave the alliance; if fewer than two members remain it dissolves.
  • Brand-new alliances form among players who are not currently in one, so the number and size of new alliances shrink as the cast becomes allied.
  • Occasionally (30% of episodes) a player also joins a second, side alliance — a double agent playing multiple sides. Membership is not capped, but every existing alliance halves a player's formation weight, so being in many alliances at once is rare.
  • As a result, a player's allies change as alliances shrink, dissolve, and reform, and a large alliance can shed members who then form a new alliance together.

Re-simulating Existing Seasons

Formation consumes a different sequence of random draws than before, and foe strength now uses the current episode's ballots. Both change stored Episode.results. The simulator is deterministic per seed, so a season must be re-simulated from a chosen episode onward to reflect the new algorithm; previously stored episodes keep their old connections and alliances until they are re-run.

Alliance Persistence Across Merge

Alliances formed during the pre-merge phase carry over across the merge transition and into post-merge episodes:

  • Existing alliances are preserved in state.existingAlliances rather than being reset at merge.
  • In both pre-merge and post-merge episodes, every existing alliance undergoes standard social-based dissolution rolls (breakChance = 0.1 * (1 - avgSocialRating) * minBreakMultiplier) plus per-member defection.
  • Membership can overlap (a player may play multiple sides), but a per-existing-alliance weight penalty makes many simultaneous alliances rare.
  • Pre-merge alliances that remain intact continue to protect former tribemates in post-merge tribal council voting (weight *= 0.2) and idol plays (+0.15 target boost).

Phase 5: Idol Finding

Source: advantages.ts:resolveAdvantages()

Finding Mechanics

Each player without an idol is checked in order. At most 1 idol (CONCURRENT_IDOL_LIMIT) can be found per episode. The season has a maximum of 5 idols (SEASON_IDOL_CAP) ever introduced.

For each eligible player: if rng() < idolFindChance → player finds an idol.

Idol Find Chance Formula

computeIdolFindChance(historicalIdolCount, simulatedIdolFinds, directiveBonus):
  baseChance = historicalIdolCount > 0 ? 0.04 : 0.02
  simulatedBonus = min(simulatedIdolFinds * 0.05, 0.15)
  return clamp(baseChance + simulatedBonus + directiveBonus, 0.02, 0.55)

Breakdown:

  • Players who found idols in real Survivor history start at 0.04 base; others at 0.02
  • Each prior simulated idol find adds +0.05, up to +0.15 total from simulated history
  • search_idol directives add +0.18 each (stackable)
  • Final chance is clamped between 0.02 (minimum) and 0.55 (maximum)

Phase 6: Idol Play Probabilities

Source: engine.ts lines 258–296 and probabilities.ts:computeIdolPlayProbability()

Before tribal council, each active player's probability of playing an idol is computed.

Base Probability

computeIdolPlayProbability(simulatedIdolFinds, simulatedIdolPlays, historicalIdolCount, placement, isHeavilyTargeted):
  wentFar = placement <= 5
  hasManyIdols = historicalIdolCount >= 3
  defaultPlayRate = (wentFar && hasManyIdols) ? 0.35 : 0.25

  effectivePlayRate = simulatedIdolFinds > 0
    ? (simulatedIdolPlays / simulatedIdolFinds) * 0.5 + defaultPlayRate * 0.5
    : defaultPlayRate

  threatModifier = isHeavilyTargeted ? 0.15 : 0
  return clamp(effectivePlayRate + threatModifier, 0.1, 0.9)
ConditionBase Rate
Placed ≤5 AND had ≥3 historical idols0.35
Everyone else0.25

The base rate is blended 50/50 with the player's simulated play rate (plays/finds) if they've found idols in this simulation. This creates emergent behavior where players who habitually play idols continue doing so.

Threat and Ally Modifiers

  1. Heavily targeted: If 2+ directives target this player → +0.15 to idol play probability
  2. Ally under threat: If an alliance member is heavily targeted → +0.15 to idol play probability
  3. All probabilities clamped to [0.1, 0.9]

Fallback

If computeIdolPlayProbability() throws an error, the fallback is DEFAULT_IDOL_PLAY_PROBABILITY = 0.3.


Phase 7: Tribal Council

Source: tribal.ts:resolveTribalCouncil()

This is the most complex phase. It determines who gets voted out and produces the complete vote breakdown for display.

Pre-Merge Tribe Scoping

Source: engine.ts:simulateEpisode() (Phase 6 dispatch) + the pure helpers computeLosingTribePlacements() and computeTribeEliminationCounts()

During pre-merge episodes (episodePhase === 'pre_merge'), tribal council is placement-based and single-tribe: each attending tribe holds its own council, and cross-tribe voting is impossible by construction. The engine derives attendance from the challenge's tribeScores before invoking resolveTribalCouncil once per attending tribe.

  • Placement: The losing (non-winning) tribes are ordered worst-first by ascending tribeScores[].score (lowest score = last place). The immunity-winning tribe is always excluded from attendance, even if it happened to hold the lowest score.
  • Distribution rule: Let K = eliminationCount and M = losing tribes with at least one active member, ranked worst-first [t1, t2, …, tM]:
    • If K ≤ M: only the K worst-placed tribes attend, each eliminating exactly 1; the remaining M − K losing tribes are safe.
    • If K > M: every losing tribe eliminates a base of 1, then the remaining K − M eliminations are assigned one at a time cycling t1, t2, …, tM, t1, … (worst-first). Example: 3 tribes with one winner and K=5 → last-place eliminates 3, second-to-last eliminates 2.
    • Tribes with zero active members are excluded before distribution; their counts roll into the reduced M.
  • Per-tribe council: Each attending tribe's active players are passed to resolveTribalCouncil with eliminationCount = tribeCount (sequential rounds inside the tribe). target_vote directives, alliances (kept only if ≥ 2 eligible members), lay-low players, and idol_play directives are all filtered to the attending tribe. Winning-tribe members are entirely absent — they cannot vote and cannot be targeted, and their idols are preserved for future episodes.
  • Aggregation: Per-council results are concatenated worst-first into the single TribalCouncilResult (votedOut, votedOutNames, votedOutSeasonNumbers, voteBreakdown, voteRationales, idolPlays, advantagePlays); votedOutName/votedOutSeasonNumber take the first element and rockDraw = any council rockDraw. Each pre-merge voteBreakdown and voteRationales entry is stamped with its attending tribe's tribeId/tribeName, and a councils[] summary records each attending tribe (tribeId, tribeName, eliminationCount, and its votedOut/votedOutNames/ votedOutSeasonNumbers slices plus a per-council rockDraw flag), enabling the episode page to show which tribes attended, how many each eliminated, and which tribe's council ended in a rock draw after a deadlocked revote.
  • Short-circuits: eliminationCount === 0, merge episodes, episodes with no winningTribeId, or "no losing tribe with active members" all return the neutral empty result (no vote-out, empty breakdown).

Post-merge behavior (episodePhase !== 'pre_merge' or no winningTribeId): All active players participate in a single all-player council, matching existing individual tribal council behavior. The immunity winner (individual) is excluded from targets but can vote. councils and per-entry tribeId/tribeName are omitted for post-merge results.

6a. Idol Plays

Each eligible player (excluding the immunity winner) with an idol independently rolls against their idolPlayProbability:

  • If they play the idol, it protects themselves
  • Nullified votes: Math.ceil(rng() * 5) — a random number from 1 to 5
  • If nullified votes > 0, the player is added to the idol-protected set — they cannot receive votes

6b. Vote Weight Formula

Each player (including the immunity winner) casts one vote for an eligible target. Eligible targets: active players who are not the voter themselves and not idol-protected.

For each voter→target pair, the base weight is:

baseWeight = 0.4 + target.socialRating * 0.3 + target.strategicRating * 0.3

Then directive contributions are added:

+ 0.3 for each directive that targets this player (they look threatening)
+ 0.1 for each directive where this player is the director (they look strategic)

Then modifiers are applied:

ModifierConditionEffect
Alliance protectionVoter and target share a record in the episode's canonical alliance stateTarget is excluded from the voter's candidate list unless a documented override applies; loyalty only strengthens protection
Directive modifierVoter has a target_vote directive pointing to this targetWeight multiplied by ×3.0 (strong bias toward the directed target)

The canonical alliance state is built once per episode in src/lib/simulation/alliance-state.ts and is the only source for vote protection, the alliance graph/connections, the timeline events, and story evidence. A pair is allied only when one active AllianceRecord contains both players; separate blocs with no shared record are not treated as allies.

Allied targets are excluded unless one of these documented overrides applies: a target_vote directive (directive), a configured high- or low-threat bias (threat_high / threat_low), a tie/revote (tie_revote), or a council where every eligible target is an ally (no_alternative). Every overridden ally ballot records the override in VoteRationale.alliance. voteAllyMultiplier is clamped to ≤ 1, so loyalty can only suppress, never increase, an ally's weight.

Final weight is clamped to >= 0. A target is selected via weightedPick().

6c. Vote Tallying

Votes are counted per target. The player with the most votes (plurality) is eliminated.

6d. Tie-Breaking

When 2+ players share the most votes:

  1. Revote: Non-tied voters cast a second vote, restricted to only the tied players. Only tied players cannot vote in the revote; the immunity winner (who can never be part of a tie since they cannot receive votes) participates like any other eligible voter. Weights use the base formula without directive modifiers. Canonical alliance protection still applies: allied tied targets are excluded when at least one non-allied tied target exists. If every tied target is allied, the ballot records tie_revote as the override.

  2. Rocks (if revote still tied): Random elimination among persistently tied players:

    eliminatedIndex = Math.floor(rng() * tiedPlayers.length)
    

6e. Fallback — No Votes Cast

If no votes were cast (all players are immune or idol-protected), the system falls back to a weighted threat-based elimination:

threatWeight = 0.4 + socialRating * 0.3 + strategicRating * 0.3
if idolProtected → threatWeight *= 0.1  (idol holders are much harder to eliminate)

The eligible player with the highest weighted probability is eliminated.

6f. Immunity Winner Voting

The immunity winner can vote but cannot be voted against. They are excluded from the eligible targets list but participate in vote casting (as fixed in spec 056).

6g. Vote Rationales and Voting Story

resolveTribalCouncil emits a voteRationales[] array that parallels voteBreakdown 1:1 (same order, same length). Each rationale is a per-vote, human-readable explanation of why the voter chose their target, captured additively at vote time on the actual chosen target (no raw weights/probabilities are persisted):

Reason flagTrigger
social/strategic threattarget's base threat weight ≥ 1.0
moderate threatbase weight 0.7–1.0
low threatbase weight < 0.7
following directive targetvoter's target_vote directive points at the target, including an ally
directive target for another voteranother voter's directive points at the target
target laying lowtarget is in the lay-low set
high-threat targeting biasvoter applied a threat-high multiplier > 1 to the target
low-threat targeting biasvoter applied a threat-low multiplier > 1 to the target
tie/revote overridean allied tied target was only selectable because of the revote rule
no non-allied targetevery eligible target was an ally

VoteRationale.alliance carries the structured context: { allied, sharedAllianceIds?, override? }. Any ballot against an ally must carry an override; non-allied ballots record { allied: false }. Revote ballots use the same shape and record tie_revote when protection had to yield.

After the episode is simulated, simulation.service.ts generates a voting story per council (src/lib/tribal/narrative.ts): a single story on TribalCouncilResult.story for one-council episodes, and one story on each councils[i].story for multi-council pre-merge episodes. Generation mirrors the challenge narrative: generateVoteStory() calls OpenAI gpt-4o-mini with the vote breakdown + per-vote reasons, and falls back to getVoteFallbackStory() (a deterministic camp-dynamics recap that also covers rock draws, double councils, and pre-feature episodes with no rationale data) when no OPENAI_API_KEY is set or the API fails. Both paths focus on the social dynamics back at camp between the challenge and the vote, and are grounded in the simulated world state (all scoped to the council's tribe):

  • Alliances — the canonical alliances.records snapshot (with legacy fallback to currentAlliances) plus that episode's formed/broken alliances, all scoped to the council's tribe.
  • Connections graph — the pre-vote connectionsPreVote snapshot when present, falling back to the post-episode connections for legacy episodes. Current versus historical alliance edges are distinguished by the active flag.
  • Player stats — each player's socialRating/strategicRating/challengeWinRate plus the in-season adjustments from computePlayerStatAdjustments (challenge wins, challengeBonus, threatShift, socialBoost, strategicBoost).
  • Episode challenge context — the immunity challenge type, winner (individual winner or winning tribe), and top performers.

The narrative must be consistent with the sim (e.g., the individual immunity winner cannot be voted out) and must not recite raw weights, threat scores, or the vote-by-vote tally. The shared narrative quality gate (src/lib/narrative/quality.ts) additionally rejects a betrayal/turncoat claim unless an allied-to-ally ballot is recorded, and rejects an explicit alliance pair that is not in the canonical state. Rejected stories retry once and fall back to the deterministic story. The story is persisted in episode.results and rendered read-only on the episode page — no runtime AI fetch during page render.

Challenge and Reward Narratives

Immunity and reward challenges get narratives on ChallengeResult.narrative / RewardChallengeResult.narrative (src/lib/challenge/narrative.ts). Tribal-state challenges (with tribeScores) use generateNarrative(), keyed by margin classification (close/moderate/dominant) against the runner-up tribe. Individual challenges — post-merge individual immunity and individual ('individual' mode) reward challenges — use generateIndividualChallengeNarrative(), keyed by the same margin classification computed from the winner's vs. runner-up's challenge weights. Both call OpenAI gpt-4o-mini with a build*NarrativePrompt() prompt grounded in the participating players and their weights, and fall back to a deterministic template (getIndividualGenericFallback()) when no OPENAI_API_KEY is set or the API fails. The narratives are persisted in episode.results and rendered read-only on the episode page.


Final Four Fire-Making (Penultimate Episode)

Source: engine.ts:simulateEpisode() + final-four.ts:resolveFinalFour()

On the penultimate episode (episode totalEpisodes - 1) with exactly 4 active players, the tribal vote is replaced by a fire-making selection. No ballots are cast.

Trigger

isFinalFour =
  activePlayers.length === FINAL_TRIBAL_THRESHOLD + 1   // 4 players
  && state.episodeNumber === state.totalEpisodes - 1     // penultimate episode
  • A 4-player episode that is not penultimate falls through to the normal vote (elimination count logic is unchanged).
  • The finale episode never reaches the engine's tribal section (the service routes it via the isFinale path), and the episodeNumber === totalEpisodes case can never match totalEpisodes - 1.
  • forceTie / forceRocks are no-ops for final-four episodes.

Safe Pick

The immunity winner (challengeResult.winnerId) is automatically safe. The safe third finalist is picked among the three remaining active players via weightedPick:

selectionWeight =
  areAllied(immunityWinnerId, candidateId, currentAlliances)
    ? SAFE_PICK_ALLIANCE_WEIGHT    // 2.5
    : SAFE_PICK_NEUTRAL_WEIGHT     // 1.0

areAllied is true when the candidate shares any alliance pair with the immunity winner in the episode's currentAlliances.

Fire-Making Match-Up

The two players who are neither the immunity winner nor the safe pick compete head-to-head:

fireMakingWeight = challengeWeight * (0.8 + rng() * 0.4)
fireMakingWinner  = weightedPick(participants, player => fireMakingWeight, rng)
fireMakingLoser   = the other participant

Result Shape

results.finalFour is attached to EpisodeResults (see Data contract in the plan). Every player is a PlayerRef = { playerId, playerName, seasonNumber }:

finalFour?: {
  immunityWinner: PlayerRef   // the safe immunity winner
  safePick: PlayerRef         // the other finalist chosen by the immunity winner
  participants: PlayerRef[]   // exactly 2 — the fire-making match-up
  winner: PlayerRef           // fire-making winner = third finalist
  loser: PlayerRef            // eliminated; mirrors tribalCouncil.votedOut
  story?: string              // post-simulation final-four narrative (see below)
}

Final-Four Story

After simulation, simulation.service.ts generates a final-four story on results.finalFour.story (src/lib/tribal/narrative.ts:generateFinalFourStory()) whenever a final-four episode is present. It replaces the ballot story on the episode page (no ballots are cast, so a generateVoteStory recap would be meaningless). Generation mirrors the voting story: OpenAI gpt-4o-mini grounded in the simulated world state, with a deterministic fallback (getFinalFourFallbackStory()) when no OPENAI_API_KEY is set or the API fails. The prompt/fallback cover:

  • Why the safe pick — the immunity winner's choice grounded in the current alliance snapshot (an allied partner is described as a trusted bond; a non-allied safe pick as calculated risk).
  • The fire-making duel — who was sent to fire, who caught flame first, and the loser's in-season challenge record (if available) as color.
  • Grounding — the same alliances/connections/player-stats/challenge context used by the voting story; no raw weights or probabilities are recited. The story is persisted in episode.results.finalFour.story and rendered read-only on the episode page.

Bookkeeping (unchanged downstream)

To keep the existing elimination/idol-pool/standings/roster code paths working, the final-four branch still emits a tribalCouncil record:

  • votedOut / votedOutNames / votedOutSeasonNumbers are set from the fire-making loser.
  • voteBreakdown, idolPlays, advantagePlays are empty and rockDraw is false.
  • The fire-making loser is post-merge (the merge is forced on the penultimate episode), so they join the jury for the following finale.
  • The finale episode receives exactly three finalists (safe pick + fire-making winner + immunity winner) and resolves a jury vote with a 3-entry juryVoteBreakdown — no finalist-collection change is required in simulation.service.ts.

Finale & Jury Vote Story

Source: src/lib/simulation/finale.ts (resolveFinale), src/lib/tribal/narrative.ts (generateFinaleStory), src/services/simulation.service.ts (isFinale || isEarlyEnd branch)

The finale is resolved outside the engine: the service hand-builds an EpisodeResults stub, collects jury members from the season's merge/post-merge vote-outs, and calls resolveFinale(finalists, juryMembers, rng). The result is stored on results.finale:

finale?: {
  winnerId: string
  winnerName: string
  winnerSeasonNumber: number
  juryVoteBreakdown: {
    playerId: string
    playerName: string
    seasonNumber: number
    historicalVotes: number      // base jury appeal carried into the season
    campaignBonus: number        // accumulated campaign_jury directive bonus
    randomBonus: number
    totalJuryVotes: number
    jurorVotes: { jurorId: string; jurorName: string }[]
  }[]
  tieResolution?: {
    type: 'deciding-vote' | 'random'
    tiedPlayers: PlayerRef[]
    decidingVoter?: PlayerRef
  }
  story?: string                 // NEW: generated finale narrative
}

Finale Story

After the winner and breakdown are known, simulation.service.ts sets results.finale.story = await generateFinaleStory(...). Because the finale path never runs simulateEpisode, its narrative context is built explicitly rather than from the non-finale buildAlliances/buildConnections closures (those are scoped to the else branch and depend on episode-scoped results.connections/currentAlliances, which the finale never produces):

  • Finalists and jury — every finalist's name, season number, and total jury votes; the jury-member names; and each finalist's juror attributions.
  • Tie resolution — the deciding-vote/random type, tied players, and the deciding juror when present. The prompt and fallback describe the tie-break truthfully and never claim an outright majority when the vote was tied.
  • Season history — state.existingAlliances (name-mapped as the current alliance snapshot) and finalist/juror season stats from computeSeasonPlayerStats + computePlayerStatAdjustments.

Generation mirrors the voting and final-four stories: OpenAI gpt-4o-mini, 2 attempts with a 20s per-request timeout, output accepted only at 50–3,000 characters, and a deterministic fallback (getFinaleFallbackStory, pure string construction — no RNG/clock/environment) when OPENAI_API_KEY is unset or generation fails. generateFinaleStory never throws, so a finale cannot fail because of narration. The story is persisted inside the existing episode-results JSON (no schema or migration change) and rendered read-only:

  • Episode page — "Jury Vote" card, below the winner banner and per-finalist vote tallies.
  • Season view — "Season Complete" card, below the jury vote breakdown.

Historical finales without a story render exactly as before.


Phase 8: Standings

Source: scoring.ts:calculateStandings()

Manager standings are determined by the elimination order of their drafted players:

  • Primary rank: Each manager's longest-surviving drafted player determines their primary rank — the later a player is eliminated (or if still active), the higher the manager ranks
  • Tiebreaking: If two or more managers have the same longest-surviving player elimination order, the tie is broken by comparing their second-longest surviving player, then their third, fourth, etc.
  • Empty rosters: A manager with no drafted players (empty roster) ranks last
  • Active players: Players who are still in the game (not yet eliminated) count as surviving past the current episode

Standings are recalculated after each episode as players are eliminated. The manager whose roster outlasts the rest places first.


Phase 9: Idol Pool Update

Source: ids.ts:computePoolUpdate()

After the episode, the idol pool is adjusted based on what happened:

computePoolUpdate(current, foundCount, playedCount, eliminatedHolderCount, cap=5, limit=1):
  // 1. Found idols are removed from the available pool
  available = max(0, available - foundCount)

  // 2. Played idols are recycled back into the pool
  for each played idol:
    if everIntroduced < cap:
      everIntroduced++              // count it as a new introduction
      available = min(limit, available + 1)   // add back, capped at concurrent limit
    else:
      available = min(limit, available + 1)   // recycle without counting

  // 3. Idols held by the eliminated player are returned to the pool
  for each eliminated holder:
    available = min(limit, available + 1)

Idol Pool Rules

RuleConstantValue
Max idols in play at onceCONCURRENT_IDOL_LIMIT1
Max idols ever introduced per seasonSEASON_IDOL_CAP5
Played idols are recycled—Returned to available pool
Eliminated holders' idols are recycled—Returned to available pool

Phase 10: Connection Graph Percentages

Source: engine.ts:computeConnectionPercentages() and computeFoePercentages()

For the force-directed graph visualization on the episode page, connection strengths are computed as percentages.

Alliance Connections

For each pair of active players:

strength = (episodesTheyWereAlliedTogether / episodesBothWereActive) * 100
  • Iterates through all episodeAlliancesHistory entries
  • Counts episodes where both players were in any alliance as "both were active"
  • Counts episodes where they were in the same alliance as "allied together"
  • Includes the current episode's active alliances in the count

Foe Connections

For each pair of active players (directed: voter → target):

strength = (timesVoterHasVotedForTarget / episodesBothWereInTribalTogether) * 100
  • Iterates through all episodeVoteHistory entries
  • Counts each episode where both were in tribal (both appear in vote records)
  • Counts each time the voter cast a vote against the target
  • Only connections with strength > 0 are included

Merging

Alliance and foe connections are merged by player ID into a single ConnectionEntry array:

For each player:
  connections = [alliance connections...] + [foe connections...]

Each connection is tagged with type: 'alliance' or type: 'foe' for the force graph to render in different colors.


Deterministic PRNG System

Source: prng.ts

mulberry32

The engine uses mulberry32, a 32-bit deterministic PRNG with excellent statistical properties for its size:

mulberry32(seed):
  state = seed | 0
  return () => {
    state = (state + 0x6D2B79F5) | 0
    let t = Math.imul(state ^ (state >>> 15), 1 | state)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296  // returns [0, 1)
  }

Seed Generation

Seeds are generated from strings using a djb2 hash:

seedFromString(str):
  hash = 0
  for each character:
    hash = ((hash << 5) - hash + charCode) | 0
  return hash

The seed is typically constructed from the league/season ID and episode number, ensuring each episode has a unique but reproducible random sequence.

weightedPick

Selects an item from an array with weighted probabilities:

weightedPick(items, getWeight, rng):
  total = sum of all weights (clamped to >= 0)
  if total is 0:
    return items[floor(rng() * items.length)]  // uniform fallback

  threshold = rng() * total
  for each item:
    threshold -= max(0, weight)
    if threshold <= 0:
      return item

  return last item  // safety fallback

Why Determinism Matters

  • Debugging: A problem episode can be replayed exactly by reusing its seed
  • Fairness: All outcomes derive from the same seed, not external randomness
  • Testing: Unit tests verify exact outcomes for known inputs
  • Trust: Players can verify that results are reproducible

Data Flow

From API to Engine and Back

POST /api/leagues/[leagueId]/simulate/trigger
  │
  ▼
triggerEpisodeSimulation(leagueId)           // simulation.service.ts
  ├── Load DB state:
  │   ├── Active roster players with composite stats
  │   ├── Manager directives for this episode
  │   ├── Existing alliances (from prior episodes)
  │   ├── Alliance history (per-episode snapshots)
  │   ├── Vote history (per-episode vote records)
  │   └── Idol pool state (available, everIntroduced)
  │
  ├── Build SimulationState
  │
  ├── Call simulateEpisode(state)            // engine.ts
  │   └── Returns EpisodeResults
  │
  ├── Persist results:
  │   ├── Create Episode record with full EpisodeResults JSON
  │   ├── Update roster player status (active → eliminated for voted-out)
  │   ├── Update player idol inventories (found, played, lost)
  │   ├── Update player simulated stats (challenge wins, idol finds, idol plays)
  │   └── Handle season completion (finale, winner determination)
  │
  └── Return episode results for UI display

Roster Reconstruction

When viewing past episodes, the system reconstructs each player's status (active/eliminated) and idol inventory using reconstructRosterState() in src/lib/roster-reconstruction.ts. This replays earlier episode results chronologically to determine the state at any historical point without storing full snapshots for every episode.


Edge Cases & Fallbacks

No Active Players

If activePlayers.length === 0, the engine returns a minimal empty result immediately:

  • Challenge: { winnerId: '', type: 'none', description: 'No active players' }
  • Tribal council: { votedOut: '', voteBreakdown: [], idolPlays: [] }
  • Idol pool: unchanged state

All Players Immune or Idol-Protected

If no votes are cast at tribal (every possible target is either immune or idol-protected), the system falls back to weighted threat-based elimination:

threatWeight = 0.4 + socialRating * 0.3 + strategicRating * 0.3
if idolProtected → threatWeight *= 0.1

The player with the highest weighted probability is eliminated. Idol protection reduces threat by 90% but does not guarantee safety.

Persistent Tie (Rocks)

If the initial vote is tied and the revote is still tied:

  • A random player among the persistently tied is eliminated
  • eliminatedIndex = Math.floor(rng() * tiedPlayers.length)

Probability Calculation Error

If computeIdolPlayProbability() throws an error:

  • Falls back to DEFAULT_IDOL_PLAY_PROBABILITY = 0.3
  • This is a safety net; in practice the formula should never throw

Zero-Weight weightedPick

If all items in weightedPick() have zero total weight:

  • Falls back to uniform random selection
  • return items[Math.floor(rng() * items.length)]

Single Eligible Player at Tribal

If only one player is eligible to receive votes:

  • They receive all votes by default
  • No tie-breaking needed

Future Considerations

form_alliance Directive

The form_alliance directive type produces a forced [playerId, targetPlayerId] pairing for each selected ally in the DirectiveEffects.alliancesToForm object. A directive may name one or more allies, so one directive can produce several pairings. During an episode, each pairing is promoted into the canonical alliance state when both players are active and no active record already contains both. Promoted pairs are appended to alliances.formed with origin: 'directed', so they appear in the alliance timeline and receive the same absolute protection as organic alliances. They are deduplicated by membership set.

Challenge Type Is Cosmetic

The challenge type (endurance/puzzle/obstacle/balance/memory) is randomly selected for flavor but does not affect the probability calculation. A future enhancement could make different player stats favor different challenge types.

Advantage Plays Beyond Idols

The AdvantageResult type includes idolPlays and advantagePlays fields, and TribalCouncilResult includes advantagePlays. Currently, only idols are implemented. Future advantage types (steal-a-vote, extra vote, immunity necklace, legacy advantage) could be added to the system.


Plain-Language Guides

Directives: What Your Choices Do

As an Island of Eras manager, you can issue strategic directives to your roster players before each episode. These directives influence how your players behave during the simulation.

Throw Challenge — Tell your player to intentionally do poorly in the challenge.

  • Effect: Your player is half as likely to win immunity (challenge win rate × 0.5)
  • When to use: You want someone else to win, or you want your player to appear weak
  • Risk: Your player won't get immunity protection

Search for Idol — Tell your player to search for a hidden immunity idol.

  • Effect: Your player's idol find chance increases by 18% (e.g., from 4% to 22%)
  • When to use: Your player is in danger of being voted out
  • Can be issued multiple times for even higher chance (each directive adds +18%)

Target Vote — Tell your player to target a specific person at tribal council.

  • Effect: Your player's vote weight toward that target is tripled (×3)
  • When to use: You want a specific player eliminated
  • Your player will still consider other factors, but the directive creates a strong bias

Important: Directives shift probabilities — they do not guarantee outcomes. The simulation uses weighted random selection, so even a strong directive bias may not produce the exact result you want.

Standings: How Rankings Are Determined

Your league rank is determined by the elimination order of your drafted players:

  • Each manager's ranking is based on how long their longest-surviving drafted player stayed in the game
  • If two managers have the same longest-surviving player elimination order, the tie is broken by comparing their second-longest surviving player, then third, and so on
  • A manager with no drafted players ranks last
  • Active players (still in the game) count as surviving past the current episode

Your rank improves as your players outlast those drafted by other managers.

Voting: How Tribal Council Works

At tribal council, every active player casts one vote. Here's what determines who they vote for:

  1. Base threat: Players with high social and strategic ratings are naturally targeted
  2. Directives: Your target_vote directives strongly bias your player's vote (×3 weight)
  3. Alliances: Your player is 80% less likely to vote for someone in their alliance
  4. Idol protection: If someone plays an idol, they cannot receive votes at all
  5. Immunity winner: Cannot receive votes, but can still vote

The player with the most votes is eliminated. If there's a tie, there's a revote. If still tied, it goes to "rocks" (random elimination). If nobody can receive votes, the most threatening player among the eligible is eliminated by weighted random pick.


Key Constants Reference

Idol System Constants

ConstantValueSourceDescription
BASE_IDOL_FIND_RATE0.02ids.tsBase idol find chance for players with no historical idol finds
HISTORICAL_IDOL_FIND_RATE0.04ids.tsIdol find chance for players who found idols in real Survivor history
SEARCH_IDOL_DIRECTIVE_BONUS0.18ids.tsAdditional chance per search_idol directive
CONCURRENT_IDOL_LIMIT1ids.tsMax idols that can exist simultaneously in the game
SEASON_IDOL_CAP5ids.tsMax idols ever introduced in a single season
IDOL_PLAY_BASE0.1ids.tsMinimum idol play probability
IDOL_PLAY_MAX0.9ids.tsMaximum idol play probability
IDOL_PLAY_THREAT_COEFF0.5ids.tsWeight for historical vs simulated play rate blending

Idol Find Probability Constants

ParameterValueSourceDescription
Simulated find bonus increment+0.05/findprobabilities.tsBonus per prior simulated idol find
Simulated find bonus cap+0.15probabilities.tsMaximum simulated idol find bonus
Idol find chance floor0.02probabilities.tsMinimum possible idol find chance
Idol find chance ceiling0.55probabilities.tsMaximum possible idol find chance

Idol Play Probability Constants

ParameterValueSourceDescription
Default play rate (went far + many idols)0.35probabilities.tsFor players who placed ≤5 AND had ≥3 historical idols
Default play rate (everyone else)0.25probabilities.tsBase idol play rate for most players
Threat modifier (heavily targeted)+0.15probabilities.tsAdditional chance if 2+ directives target this player
Ally protection bonus+0.15engine.tsAdditional chance if an alliance member is heavily targeted
Fallback play probability0.3engine.tsUsed if computeIdolPlayProbability() throws

Challenge Constants

ParameterValueSourceDescription
Challenge win bonus increment+0.04/winprobabilities.tsBonus per prior simulated challenge win
Challenge win bonus cap+0.30probabilities.tsMaximum cumulative challenge win bonus
Weight random noise factor0.8–1.2challenge.tsRandom multiplier on challenge weight: 0.8 + rng() * 0.4

Alliance Constants

ParameterValueSourceDescription
Allied share of group0.60–0.85 (100% 25% of the time)alliances.tsShare of a group placed into blocs
Blocs per group1–4 (average ~3 members)alliances.tsChosen so the average bloc stays around three
Bloc size range2–6alliances.tsBloc sizes are balanced within these bounds
Minimum bloc size2alliances.tsAn alliance needs at least two members
Outsany leftover seatsalliances.tsPlayers not assigned to a bloc are on the outs
Defection chance0.12 per member/episodealliances.tsChance a member leaves an existing alliance
Side alliance chance0.3 per group/episodealliances.tsChance to form an extra double-agent alliance
Membership weight penalty×0.5 per alliancealliances.tsEach existing alliance halves formation weight (soft, uncapped)

| Alliance break factor | 0.1 | alliances.ts | breakChance = 0.1 * (1 - avgSocialRating) | | Alliance member selection: social weight | 0.7 | alliances.ts | Social rating contributes 70% of selection weight | | Alliance member selection: strategic weight | 0.3 | alliances.ts | Strategic rating contributes 30% of selection weight | | Season-progress strength floor/ceil | 0.25 → 1.0 | engine.ts | clamp(0.25 + 0.75·(ep−1)/(total−1), 0.25, 1) |

Tribal Council Constants

ParameterValueSourceDescription
Vote base weight0.4tribal.tsFloor value in vote weight formula
Social weight coefficient0.3tribal.tstarget.socialRating * 0.3
Strategic weight coefficient0.3tribal.tstarget.strategicRating * 0.3
Directive target threat bonus+0.3tribal.tsWeight bonus per directive targeting this player
Directive director threat bonus+0.1tribal.tsWeight bonus per directive where player is the director
Alliance vote modifier×0.2tribal.tsAllies rarely vote for each other (80% reduction)
Directive vote modifier×3.0tribal.tstarget_vote strongly biases voting
Idol nullified votes range1–5tribal.tsMath.ceil(rng() * 5)
Idol protection threat reduction×0.1tribal.tsIdol-protected players in fallback threat elimination

Final Four Fire-Making Constants

ParameterValueSourceDescription
FINAL_TRIBAL_THRESHOLD3constants.tsFinal tribal size; the fire-making trigger fires at 4 (THRESHOLD + 1) active
Fire-making trigger4 players @ penultimateengine.tsactivePlayers.length === FINAL_TRIBAL_THRESHOLD + 1 && episodeNumber === totalEpisodes - 1
SAFE_PICK_ALLIANCE_WEIGHT2.5final-four.tsSafe-pick weight when the candidate shares an alliance with the immunity winner
SAFE_PICK_NEUTRAL_WEIGHT1.0final-four.tsSafe-pick weight for unallied candidates
Fire-making weight noise0.8–1.2final-four.tschallengeWeight * (0.8 + rng() * 0.4) in the fire-making match-up

Directive Effect Constants

DirectiveEffectSource
throw_challengeChallenge win rate ×0.5directives.ts
search_idolIdol find chance +0.18directives.ts
target_voteVote weight ×3.0 toward targettribal.ts
form_allianceForced alliance pair recorded in canonical state and timelinedirectives.ts + engine.ts + tribal.ts
Heavy target threshold2+ directivesengine.ts

Implementation Notes

Simulation Service Bridge

The simulation.service.ts (651 lines) is the bridge between the web API layer and the simulation engine. Key exported functions:

FunctionPurpose
triggerEpisodeSimulation(leagueId)Builds SimulationState from DB, calls simulateEpisode(), persists results
getSeasonState(leagueId, episode?)Builds season dashboard: standings, rosters, rankings; supports historical roster reconstruction
getEpisode(leagueId, episodeNumber)Fetches single episode with player and manager metadata
computeCurrentRosters()Maps DB roster state to ManagerRosterView[]

Deterministic Reproducibility

  • Given identical SimulationState with same seed, output is always identical
  • Seed typically: hash(leagueId + episodeNumber)
  • Critical for debugging (replay problem episodes), fairness, and testing

Living Document

This reference should be updated whenever simulation code changes. If you modify any constant, formula, or phase logic, update the corresponding section in this document. The future considerations section tracks known gaps — when a gap is addressed, move the item out of that section.