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
| File | Lines | Purpose |
|---|---|---|
engine.ts | 534 | Pipeline orchestrator: simulateEpisode(state) — the main entry point |
rewards.ts | 172 | Reward challenge: selectReward() menu, resolveRewardChallenge(), effect applicator |
tribal.ts | 233 | Tribal council voting, idol plays, tie-breaking, vote tallying |
challenge.ts | 41 | Immunity challenge winner selection via weighted random pick |
alliances.ts | 65 | Alliance formation (weighted sampling) and dissolution (social-based break) |
advantages.ts | 33 | Idol finding with per-player probability and concurrent limit |
directives.ts | 46 | Manager directive types → simulation modifier mapping |
scoring.ts | 60 | Manager standings based on player elimination order |
ids.ts | 47 | Idol pool constants and computePoolUpdate() state tracking |
probabilities.ts | 46 | computeChallengeWinRate(), computeIdolFindChance(), computeIdolPlayProbability() |
prng.ts | 39 | Deterministic PRNG (mulberry32), weightedPick(), shuffleArray() |
Core Data Structures
SimulationState (Input)
The input to simulateEpisode(), built by the simulation service from database records.
| Field | Type | Description |
|---|---|---|
players[] | Array | Active roster entries with composite stats (historical + simulated) |
episodeNumber | number | Current episode (1-based) |
totalEpisodes | number | Total episodes in the season |
directives[] | Array | Manager-issued directives for this episode |
seed | string | Deterministic seed for the PRNG |
existingAlliances | string[][] | Alliances formed in previous episodes |
episodeAlliancesHistory | string[][][] | 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
| Attribute | Description |
|---|---|
challengeWinRate | Base challenge win probability (0–1) |
socialRating | Social game rating (0–1) |
strategicRating | Strategic game rating (0–1) |
idolCount | Idols currently held |
historicalIdolCount | Idols found in real Survivor history |
simulatedIdolFinds | Idols found so far in this simulation |
simulatedIdolPlays | Idols played so far in this simulation |
simulatedChallengeWins | Challenge wins so far in this simulation |
placement | Historical placement (1 = winner) |
active | Whether this player is still in the game |
EpisodeResults (Output)
The complete output containing everything needed for UI rendering and DB persistence.
| Field | Type | Description |
|---|---|---|
challenge | ChallengeResult | Who won immunity, challenge type, description |
tribalCouncil | TribalCouncilResult | Who was voted out, vote breakdown, idol plays |
alliances | {formed, broken} | New alliances formed, existing alliances broken |
currentAlliances | string[][] | Alliances active after this episode |
connections | ConnectionEntry[] | Alliance and foe connection percentages (for force graph) |
idolFinds | Array | Players who found idols this episode |
idolPoolUpdate | IdolPoolUpdate | Idol pool state after this episode |
finalFour | FinalFourResult | Present 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.
| # | Phase | Source | What It Produces |
|---|---|---|---|
| 1 | Apply directives | directives.ts | Challenge multipliers, idol find bonuses, vote targets |
| 2 | Resolve reward challenge | rewards.ts | Reward type, winner (tribe/individual), effect modifiers (pre-merge & post-merge) |
| 3 | Resolve challenge | challenge.ts | Immunity winner and challenge type |
| 4 | Resolve alliances | alliances.ts | Formed alliances, broken alliances, current active alliances |
| 5 | Find idols | advantages.ts | List of player IDs who found idols this episode |
| 6 | Compute idol play probabilities | engine.ts:258-296 | Per-player probability they will play an idol at tribal |
| 7 | Resolve tribal council | tribal.ts | Vote breakdown, idol plays, eliminated player (replaced by the safe-pick + fire-making flow on the final-four penultimate episode) |
| 8 | Calculate standings | scoring.ts | Manager standings based on player elimination order |
| 9 | Update idol pool | ids.ts | Post-episode idol pool state |
| 10 | Compute connections | engine.ts:28-91 | Alliance 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:
| Condition | Merge Probability | Outcome |
|---|---|---|
activeCount > mergeTarget + mergeDeviation | 0% | Stay pre_merge |
activeCount within window [target ± deviation] | Linear from 0%→100% as player count decreases | Roll probability check |
activeCount ≤ mergeTarget - mergeDeviation | 100% (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:
- Active player count reaches or falls below
mergeTarget - mergeDeviation(lower window bound). - The episode is the penultimate episode (
totalEpisodes - 1). - The episode phase is already
mergeorpost_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
| Directive | Effect on Player | Stacking |
|---|---|---|
throw_challenge | Challenge win rate multiplied by 0.5 (halved) | No (single multiplier per player) |
search_idol | Idol find chance increased by +0.18 per directive | Yes (multiple directives add more bonus) |
target_vote | Voter 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_alliance | Forms 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.3to the weight of any player who has been targeted by a directive (they are perceived as threats)+0.1to 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)
| Reward | Effect (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:
| Reward | Effect (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)
| Reward | Effect (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 Field | Default | How It's Used |
|---|---|---|
challengeMultiplier | 1.0 | Multiplied into immunity challenge player weight computation |
socialBonus | 0.0 | Added to socialRating for alliance and tribal council calculations |
strategicBonus | 0.0 | Added to strategicRating for tribal council threat assessment |
idolFindBonus | 0.0 | Added to idol find chance before the advantage resolution phase |
allianceBreakMultiplier | 1.0 | Multiplied 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):
| Field | Type | Description |
|---|---|---|
mode | 'tribe' | Discriminant for pre-merge tribe-vs-tribe rewards |
rewardType | RewardType | The selected reward (comfort_items, etc.) |
winnerTribeId | string | ID of the winning tribe |
winnerTribeName | string | Name of the winning tribe |
tribeScores | TribeScore[] | Per-tribe scores and participants |
participants | Record<string,string[]> | tribeId → playerId[] map of post-sit-out participants |
sitOuts | SitOutRecord[] | Players who sat out this reward challenge |
targetPlayerId | string | null | For idol_clue: the randomly chosen recipient. null otherwise |
Individual mode (mode: 'individual' — post-merge):
| Field | Type | Description |
|---|---|---|
mode | 'individual' | Discriminant for post-merge individual rewards |
rewardType | RewardType | The selected reward |
winnerPlayerId | string | ID of the winning player |
winnerPlayerName | string | Name of the winning player |
participants | string[] | All active player IDs (no sit-outs post-merge) |
playerWeights | Record<string,number> | Per-player challenge weights used for winner selection |
targetPlayerId | string | null | For 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:
- Base stat: The player's inherent challenge win rate (from real Survivor history)
- Directive multiplier: If the player has a
throw_challengedirective, multiply by 0.5 - Simulated win bonus:
+0.04per 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:
- Manager selections: Players designated by managers in
Episode.sitOutsare removed first, provided they did not sit out in the immediately preceding episode (no-consecutive-sit-out rule). - 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' effectivechallengeWinRate/ 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:
| Field | Description |
|---|---|
winningTribeId | The 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:
| Signal | Source | Effect |
|---|---|---|
coVoteShare | Ballots for the same target across prior, non-nullified votes, divided by episodes both players appear | Positive |
oppositionShare | Ballots cast by one against the other, divided by shared episodes | Negative |
rewardBond | Both on the winning reward tribe (or the same individual reward group) this episode | Positive |
tribeBond | Both currently on the same tribe | Positive |
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:
- 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. - 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.
- 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
foeedge 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:
| Pair | coVotes / shared | opposition / shared | affinity |
|---|---|---|---|
| p1–p2 | 2 / 2 | 0 / 2 | 0.5 * 1 = 0.50 |
| p1–p3 | 0 / 1 | 1 / 1 | -0.5 * 1 = -0.50 |
| p3–p4 | 0 / 0 | 0 / 0 | 0.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 Social | Break Chance |
|---|---|
| 1.0 (perfect) | 0% |
| 0.7 | 3% |
| 0.5 | 5% |
| 0.3 | 7% |
| 0.0 | 10% |
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.existingAlliancesrather 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.15target 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_idoldirectives 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)
| Condition | Base Rate |
|---|---|
| Placed ≤5 AND had ≥3 historical idols | 0.35 |
| Everyone else | 0.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
- Heavily targeted: If 2+ directives target this player → +0.15 to idol play probability
- Ally under threat: If an alliance member is heavily targeted → +0.15 to idol play probability
- 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 = eliminationCountandM= 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 remainingM − Klosing tribes are safe. - If
K > M: every losing tribe eliminates a base of 1, then the remainingK − Meliminations are assigned one at a time cyclingt1, 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.
- If
- Per-tribe council: Each attending tribe's active players are passed to
resolveTribalCouncilwitheliminationCount = tribeCount(sequential rounds inside the tribe).target_votedirectives, alliances (kept only if ≥ 2 eligible members), lay-low players, andidol_playdirectives 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/votedOutSeasonNumbertake the first element androckDraw = any council rockDraw. Each pre-mergevoteBreakdownandvoteRationalesentry is stamped with its attending tribe'stribeId/tribeName, and acouncils[]summary records each attending tribe (tribeId,tribeName,eliminationCount, and itsvotedOut/votedOutNames/votedOutSeasonNumbersslices plus a per-councilrockDrawflag), 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 nowinningTribeId, 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:
| Modifier | Condition | Effect |
|---|---|---|
| Alliance protection | Voter and target share a record in the episode's canonical alliance state | Target is excluded from the voter's candidate list unless a documented override applies; loyalty only strengthens protection |
| Directive modifier | Voter has a target_vote directive pointing to this target | Weight 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:
-
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_revoteas the override. -
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 flag | Trigger |
|---|---|
social/strategic threat | target's base threat weight ≥ 1.0 |
moderate threat | base weight 0.7–1.0 |
low threat | base weight < 0.7 |
following directive target | voter's target_vote directive points at the target, including an ally |
directive target for another voter | another voter's directive points at the target |
target laying low | target is in the lay-low set |
high-threat targeting bias | voter applied a threat-high multiplier > 1 to the target |
low-threat targeting bias | voter applied a threat-low multiplier > 1 to the target |
tie/revote override | an allied tied target was only selectable because of the revote rule |
no non-allied target | every 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.recordssnapshot (with legacy fallback tocurrentAlliances) plus that episode'sformed/brokenalliances, all scoped to the council's tribe. - Connections graph — the pre-vote
connectionsPreVotesnapshot when present, falling back to the post-episodeconnectionsfor legacy episodes. Current versus historical alliance edges are distinguished by theactiveflag. - Player stats — each player's
socialRating/strategicRating/challengeWinRateplus the in-season adjustments fromcomputePlayerStatAdjustments(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
isFinalepath), and theepisodeNumber === totalEpisodescase can never matchtotalEpisodes - 1. forceTie/forceRocksare 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.storyand 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/votedOutSeasonNumbersare set from the fire-making loser.voteBreakdown,idolPlays,advantagePlaysare empty androckDrawisfalse.- 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 insimulation.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/randomtype, 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 fromcomputeSeasonPlayerStats+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
| Rule | Constant | Value |
|---|---|---|
| Max idols in play at once | CONCURRENT_IDOL_LIMIT | 1 |
| Max idols ever introduced per season | SEASON_IDOL_CAP | 5 |
| 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
episodeAlliancesHistoryentries - 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
episodeVoteHistoryentries - 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 > 0are 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:
- Base threat: Players with high social and strategic ratings are naturally targeted
- Directives: Your
target_votedirectives strongly bias your player's vote (×3 weight) - Alliances: Your player is 80% less likely to vote for someone in their alliance
- Idol protection: If someone plays an idol, they cannot receive votes at all
- 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
| Constant | Value | Source | Description |
|---|---|---|---|
BASE_IDOL_FIND_RATE | 0.02 | ids.ts | Base idol find chance for players with no historical idol finds |
HISTORICAL_IDOL_FIND_RATE | 0.04 | ids.ts | Idol find chance for players who found idols in real Survivor history |
SEARCH_IDOL_DIRECTIVE_BONUS | 0.18 | ids.ts | Additional chance per search_idol directive |
CONCURRENT_IDOL_LIMIT | 1 | ids.ts | Max idols that can exist simultaneously in the game |
SEASON_IDOL_CAP | 5 | ids.ts | Max idols ever introduced in a single season |
IDOL_PLAY_BASE | 0.1 | ids.ts | Minimum idol play probability |
IDOL_PLAY_MAX | 0.9 | ids.ts | Maximum idol play probability |
IDOL_PLAY_THREAT_COEFF | 0.5 | ids.ts | Weight for historical vs simulated play rate blending |
Idol Find Probability Constants
| Parameter | Value | Source | Description |
|---|---|---|---|
| Simulated find bonus increment | +0.05/find | probabilities.ts | Bonus per prior simulated idol find |
| Simulated find bonus cap | +0.15 | probabilities.ts | Maximum simulated idol find bonus |
| Idol find chance floor | 0.02 | probabilities.ts | Minimum possible idol find chance |
| Idol find chance ceiling | 0.55 | probabilities.ts | Maximum possible idol find chance |
Idol Play Probability Constants
| Parameter | Value | Source | Description |
|---|---|---|---|
| Default play rate (went far + many idols) | 0.35 | probabilities.ts | For players who placed ≤5 AND had ≥3 historical idols |
| Default play rate (everyone else) | 0.25 | probabilities.ts | Base idol play rate for most players |
| Threat modifier (heavily targeted) | +0.15 | probabilities.ts | Additional chance if 2+ directives target this player |
| Ally protection bonus | +0.15 | engine.ts | Additional chance if an alliance member is heavily targeted |
| Fallback play probability | 0.3 | engine.ts | Used if computeIdolPlayProbability() throws |
Challenge Constants
| Parameter | Value | Source | Description |
|---|---|---|---|
| Challenge win bonus increment | +0.04/win | probabilities.ts | Bonus per prior simulated challenge win |
| Challenge win bonus cap | +0.30 | probabilities.ts | Maximum cumulative challenge win bonus |
| Weight random noise factor | 0.8–1.2 | challenge.ts | Random multiplier on challenge weight: 0.8 + rng() * 0.4 |
Alliance Constants
| Parameter | Value | Source | Description |
|---|---|---|---|
| Allied share of group | 0.60–0.85 (100% 25% of the time) | alliances.ts | Share of a group placed into blocs |
| Blocs per group | 1–4 (average ~3 members) | alliances.ts | Chosen so the average bloc stays around three |
| Bloc size range | 2–6 | alliances.ts | Bloc sizes are balanced within these bounds |
| Minimum bloc size | 2 | alliances.ts | An alliance needs at least two members |
| Outs | any leftover seats | alliances.ts | Players not assigned to a bloc are on the outs |
| Defection chance | 0.12 per member/episode | alliances.ts | Chance a member leaves an existing alliance |
| Side alliance chance | 0.3 per group/episode | alliances.ts | Chance to form an extra double-agent alliance |
| Membership weight penalty | ×0.5 per alliance | alliances.ts | Each 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
| Parameter | Value | Source | Description |
|---|---|---|---|
| Vote base weight | 0.4 | tribal.ts | Floor value in vote weight formula |
| Social weight coefficient | 0.3 | tribal.ts | target.socialRating * 0.3 |
| Strategic weight coefficient | 0.3 | tribal.ts | target.strategicRating * 0.3 |
| Directive target threat bonus | +0.3 | tribal.ts | Weight bonus per directive targeting this player |
| Directive director threat bonus | +0.1 | tribal.ts | Weight bonus per directive where player is the director |
| Alliance vote modifier | ×0.2 | tribal.ts | Allies rarely vote for each other (80% reduction) |
| Directive vote modifier | ×3.0 | tribal.ts | target_vote strongly biases voting |
| Idol nullified votes range | 1–5 | tribal.ts | Math.ceil(rng() * 5) |
| Idol protection threat reduction | ×0.1 | tribal.ts | Idol-protected players in fallback threat elimination |
Final Four Fire-Making Constants
| Parameter | Value | Source | Description |
|---|---|---|---|
FINAL_TRIBAL_THRESHOLD | 3 | constants.ts | Final tribal size; the fire-making trigger fires at 4 (THRESHOLD + 1) active |
| Fire-making trigger | 4 players @ penultimate | engine.ts | activePlayers.length === FINAL_TRIBAL_THRESHOLD + 1 && episodeNumber === totalEpisodes - 1 |
SAFE_PICK_ALLIANCE_WEIGHT | 2.5 | final-four.ts | Safe-pick weight when the candidate shares an alliance with the immunity winner |
SAFE_PICK_NEUTRAL_WEIGHT | 1.0 | final-four.ts | Safe-pick weight for unallied candidates |
| Fire-making weight noise | 0.8–1.2 | final-four.ts | challengeWeight * (0.8 + rng() * 0.4) in the fire-making match-up |
Directive Effect Constants
| Directive | Effect | Source |
|---|---|---|
throw_challenge | Challenge win rate ×0.5 | directives.ts |
search_idol | Idol find chance +0.18 | directives.ts |
target_vote | Vote weight ×3.0 toward target | tribal.ts |
form_alliance | Forced alliance pair recorded in canonical state and timeline | directives.ts + engine.ts + tribal.ts |
| Heavy target threshold | 2+ directives | engine.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:
| Function | Purpose |
|---|---|
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
SimulationStatewith 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.