Seeds and Determinism

In short

A run has one seed. Each system that needs randomness gets its own SeededRandom stream, made with SeededRandom.Derive(seed, streamId). Because streams never share an RNG, a shop reroll can never shift the pitches. Never renumber a stream id: it would change every seed’s run.

How it works

SeededRandom

  • Wraps System.Random(seed). Methods: Value (0..1), Range(float, float), Range(int, int), NextSeed(), Pick(list), PickWeighted(list, weight).
  • Derive(seed, stream) mixes two ints into a new seed with integer math only (identical on every platform):
x = (uint)seed ^ ((uint)stream * 0x9E3779B9)
x ^= x >> 16; x *= 0x7FEB352D; x ^= x >> 15; x *= 0x846CA68B; x ^= x >> 16
return x & 0x7FFFFFFF   // non-negative for System.Random

The streams in a run

WhatSeed it usesWhere
The innings (pitcher per inning)the raw run seed: new SeededRandom(seed)RunState constructor, RunMapGenerator.Generate
One inning’s pitchesRoundSeed(node) = Derive(Derive(Seed, PitchStream = 1), node.index)RunState.RoundSeed, passed in RoundContext.seed
Pitch type, location, speednew SeededRandom(context.seed)AtBatController builds PitcherAI
Special balls ($$$, Ghost…)Derive(rng.Seed, BallStream = 0x0BA11)PitcherAI constructor. Each pitch rolls the pitcher’s own ball first (PitcherDefinition.balls), then the park’s; a ball with an alternate (The Gale’s Headwind / Tailwind) draws once more for the 50/50.
The Phantom’s decoy ballsDerive(rng.Seed, DecoyStream = 0xDEC0)PitcherAI.MakeDecoys
Which way a curveball / changeup loopsDerive(rng.Seed, ArcStream = 0xA2C)PitcherAI.CreatePitch (arcAngle, only for arcSideways pitch types)
A hit’s spray spread and launch oddsnew System.Random(context.seed ^ 0x6b1d), one per roundAtBatController.BeginRound (_hitRandom), passed to SwingResolver.FromContact (Batting Explained)
One shop visit’s offers and its rerollsDerive(Derive(Seed, ShopStream = 2), visit)RunState.CreateShopRng; ShopService.Open passes run.CurrentIndex as the visit
The daily mapDerive(DailySeed.Today, 0xDA11) % ballparks.CountRunManager.DailyPark

Why shop rerolls can’t change the pitches

The pitch seed depends only on the run seed and the inning index. Buying, rerolling or the order of purchases draw only from the shop visit’s own RNG. The special-ball rolls, the decoys and the sideways loops also have their own streams, so none of them changes which pitch type comes next. The one exception is deliberate: where a special ball only rides some pitch types, PitcherAI leans on those types (FavouredShare = 0.8 of picks, capped at 3 in a row) using the main pitch RNG, so that lean is part of the pitch sequence itself (Pitching). Dev-menu forced pitches keep rolling as normal, so turning them off leaves the rest of the sequence unchanged.

Each shop visit starts a fresh RNG, so rerolls in one shop don’t change the first offers of the next.

Resuming a run

run.json stores only the seed, the player’s choices and totals (Save System). The innings, their pitches and the shop offers are rebuilt from the seed, so quitting in the shop resumes with the same offers, and quitting mid-inning replays that inning with the same pitches.

Daily seed

  • DailySeed.Today = int.Parse(UtcNow "yyyyMMdd"), so everyone on the same UTC date gets the same seed. DailySeed.DateKey is "yyyy-MM-dd".
  • The daily run is the seed’s map on Normal (StartDailyRun). It unlocks nothing.
  • A daily checkpoint can only be resumed on its own date (RunSaveData.dailyDate).

Co-op

  • The host picks a random seed with Random.Range(1, int.MaxValue) and sends it in StartRun with the batting order (Net Messages). Every player builds the same innings from it.
  • TurnSeed(node, turn): turn 0 uses RoundSeed(node); later turns use Derive(RoundSeed(node), turn). So each turn of an inning faces its own pitch sequence (Co-op Rules).
  • ShopSalt = LocalId + 1 on each player’s RunState. When it is not 0, the shop seed is salted again: Derive(shopSeed, ShopSalt). Each player sees their own offers; solo (ShopSalt = 0) is unchanged.

Numbers

WhatValueWhere to tune
PitchStream1RunState (never renumber)
ShopStream2RunState (never renumber)
BallStream0x0BA11PitcherAI
DailyParkStream0xDA11RunManager
DecoyStream0xDEC0PitcherAI
ArcStream0xA2CPitcherAI
Hit random salt0x6b1d (XOR with the round seed, not Derive)AtBatController
Solo run seedUnityEngine.Random.Range(1, int.MaxValue)RunManager.StartRandomRun
Batting cage seedUnityEngine.Random.Range(1, int.MaxValue) (not reproducible on purpose)BattingCage

In the code

FileWhat it does
Scripts/Core/SeededRandom.csThe RNG and Derive.
Scripts/Run/RunState.csStream ids, RoundSeed, TurnSeed, CreateShopRng, ShopSalt.
Scripts/Run/RunMapGenerator.csUses the raw seed to pick pitchers and bosses.
Scripts/Pitching/PitcherAI.csPitch RNG plus the ball-effect, decoy and arc streams.
Scripts/Batting/AtBatController.csThe per-round hit random.
Scripts/Upgrades/ShopService.csDraws offers and rerolls from the visit’s RNG.
Scripts/Progression/DailySeed.csToday, DateKey.

Gotchas

Same seed is not always the same shop

The shop draws from MetaProgression.UpgradePool(), the upgrades this player has unlocked. Two players on the same daily seed see the same innings and pitches, but different shop offers if their unlocks differ.

  • Cosmetic randomness (scoreboard cracks, crowd waves, hat tumbles, blinks, the cutscenes’ handheld camera drift and fireworks) uses UnityEngine.Random on purpose: it never touches gameplay.
  • The dev tools are not seeded: the insta-home-run spray and TEST HITS’ Random pick use UnityEngine.Random (Dev Menu and Shortcuts).
  • Need randomness in a new gameplay system? Add a new stream id; don’t borrow another system’s RNG.
  • Changing RunMapGenerator, PitcherAI or ShopService draw order changes what every existing seed produces, including saved runs and today’s daily. Between e7dba51 and 45a2167 this happened on purpose: RunMapGenerator now keeps every park’s own final boss out of the random boss pool (innings 3 and 6), and PitcherAI leans on special-ball pitches, so a seed from before gives different bosses and pitches now. Saved runs from before still load (RunSaveData version is unchanged) but replay differently.