Skip to content

Web → Unity Migration — Balance 1.0 · Container Rarity · 10 Biomes · Floor 0 FTUE (2026-08-12)

Status: IMPLEMENTED (all six sections, 2026-08-12, compile-clean) — pending play-testing per §9's audit step. Decisions taken during the port: 10-biome campaign = 7 new BiomeDef assets cloning Biome_3 (named from the first seven endlessNames, preserving the virtual-rung name sequence; the endless pool keeps the remaining five); Floor 0 selection = floorSel = -1 sentinel + SaveService.SelectedRaidFloor; LootMul = computed but dormant (faithful — the web ROOMS generator never consumed it). Covers the four web commits merged from main into bza-unity-pivot-parity (merge 06409953):

Commit Title Theme
e953ae59 new balance 1.0 onboarding difficulty, mob retune, loot economy rework, nest rewards
efd022e5 rarity fix per-biome container rarity tables (chests + nests share one roll)
2462d639 10 bioms 10-biome campaign, monotonic global-floor curves, save ladder migration
f07cc04b tutorial fixed onboarding Floor 0 raid + event-driven coach

Source of truth: config.js + game.js at merge HEAD (06409953). Unity anchors cite the current bza-unity-pivot-parity state. Follow the standing 3-step port workflow (read the cited web function first · everything through GameConfig/SO assets — write the .asset, not just the C# default · line-by-line audit after).

Architecture deltas that shape this port (verified against the Unity tree): - Unity has no prog.biomeCount/stageSizes/danger* — danger is per-BiomeDef bands (dangerBase/dangerStep/lootMul, BiomeDef.cs:107–110) resolved by Domain/Progression.Danger() and stamped once per raid (RaidBootstrap.cs:139–140_raid.Danger/_raid.LootMul), with an endless virtual-biome ladder (extraRungs, BiomeSet.Biome() clamps to last authored). - Unity's only procedural generator is the room warren (WarrenGen); map size is rolled from the rooms shape (24..64 W, derived H) — the web's stageSizes table has no Unity consumer. - The warren populator does not use spawn.threatBudget* (rooms use R.roomGuardBudget + per-room bud); MobRoster.RollRoster exists but is uncalled. - The mob intro schedule is per-EnemyDef: rankAndFile + introStep (EnemyDef.cs:31–34), stepped in MapPopulator.cs:62. - Warren raid floors spawn no gatekeeper/boss (the defense finale is the floor gate, MapPopulator.cs:354–356); gatekeeperRamp is only touched by the parked FTUE path at step 0. - The lobby is a single-biome pager (arrows + floor pips), not a tile grid (LobbyController.cs:126–138, LobbyView.RenderStages 99–123); Unity floorSel is 0-based (SaveData.floorSel), unlike the web's 1-based + literal 0 = Floor 0.


1. Global progression curves (replaces per-biome danger bands)

1.1 Web rule

The old bDanger = 1 + biome*dangerPerBiome + (stage-1)*stageDanger is gone (both knobs deleted). One monotonic global-floor curve drives rank-and-file HP/damage — no difficulty dip at biome boundaries. step = biome*stagesPerBiome + (stage-1).

// game.js progressDanger(biome, stage)
bDanger = prog.dangerBase                                              // 0.50 — B1F1 = HALF authored
        + min(step, prog.dangerSoftFromStep) * prog.dangerPerStep      // 0.11/step through step 15 (B4F1)
        + max(0, step - prog.dangerSoftFromStep) * prog.dangerLatePerStep;  // 0.02/step after
// anchors: B1F1 ×0.50 · B1F5 ×0.94 · B3F5 ×2.04 · B10F5 ×2.83 (before player-Power scaling)

Companion curves, same soft-knee shape:

// progressLootMul(biome) — gray loot/chest QUANTITY
lootMul = 1 + min(b, prog.lootSoftFromBiome /*2*/) * prog.lootPerBiome /*0.30*/
            + max(0, b - 2) * prog.lootLatePerBiome /*0.08*/;          // B10 ×2.16

// progressThreatBudget(step, base) = base + growth(step) — mob-count budget
growth = min(step, spawn.threatSoftFromStep /*15*/) * spawn.threatBudgetPerStep /*0.30*/
       + max(0, step - 15) * spawn.threatLatePerStep /*0.08*/;
// web consumers: zone garrisons, tunnel arenas, corridor room guards
// (progressThreatBudget(step, C.roomGuardBudget)), roamer budget (growth × roamBudgetFrac)

// gatekeeperRampValue(gr, "hp"|"dmg", step)
val = base + min(step, gr.softFromStep /*15*/) * perStep
           + max(0, step - 15) * latePerStep;   // hpLate 0.015 · dmgLate 0.008

1.2 Unity current state

  • Domain/Progression.cs:19–25Danger(BiomeDef, floorIndex, extraRungs) = biome.dangerBase + (floorIndex + extraRungs*floors) * biome.dangerStep; bands authored on Biome_1/2/3 assets (1/0.18/1 · 1.4/0.18/1.3 · 1.8/0.18/1.6). _raid.Danger/_raid.LootMul stamped at RaidBootstrap.cs:139–140; consumed by EnemyController.Start (EnemyController.cs:204–240).
  • Room-guard sizing: MapPopulator.cs:265–309tb = R.roomGuardBudget + bud/3, no global step term at all.
  • GameConfig.spawn has threatBudgetBase/PerStep but nothing consumes them in the warren path.

1.3 Port tasks

  1. Progression.Danger → the web global curve. Add dangerBase 0.50 / dangerPerStep 0.11 / dangerSoftFromStep 15 / dangerLatePerStep 0.02 to a GameConfig group (extend prog) and rewrite Progression.Danger(cfg, globalStep) where globalStep = virtualBiome * prog.stagesPerBiome + floorIndex (the virtual index keeps the endless ladder monotonic — same spirit as today's extraRungs). Retire/ignore BiomeDef.dangerBase/dangerStep (leave fields or delete; if left, comment them dead).
  2. Loot quantityProgression.LootMul(cfg, virtualBiome) with lootPerBiome 0.30 / lootSoftFromBiome 2 / lootLatePerBiome 0.08; replace _raid.LootMul = biome.lootMul. (Verify what consumes RaidState.LootMul — the free-scatter rig is gone, so if nothing reads it, wire it into the per-room content budget bud (MapPopulator.cs:193) so quantity actually grows again.)
  3. Threat growth — add spawn.threatSoftFromStep 15 / threatLatePerStep 0.08 and a Progression.ThreatGrowth(cfg, step) helper; apply the web's corridor rule to the warren's room guards: tb = R.roomGuardBudget + bud/3 + ThreatGrowth(step) (the warren room guard is the corridor room-guard analogue — that's the faithful mapping given no zones/roamers exist).
  4. Gatekeeper late curve — add softFromStep 15 / hpLatePerStep 0.015 / dmgLatePerStep 0.008 to GameConfig.gatekeeperRamp for parity, and fix the existing gap while there: the FTUE boss assignment (MapPopulator.cs:411–418) applies only hpBase/dmgBase — implement GatekeeperRampValue(gr, step) so hpPerStep/dmgPerStep (and the late slope) are honored whenever a gatekeeper next spawns at step > 0. Low priority: no warren floor spawns one today.
  5. GameConfig.asset: write all new fields.

2. Enemy retune + intro schedule + speed

2.1 Web rule

Enemy Field Old → New
spider hp / atk 90 → 50 / 24 → 14
emberball hp / atk / projDmg 80 → 40 / 14 → 7 / 26 → 6
  • applyBurn burn DoT: burnDmg 16 → 1 (burn becomes a tell, not a killer).
  • enemyScale.enemySpeedMul 1.0 → 0.5 (non-boss roster mobs + rivals at HALF player pace; defense enemies keep their own spd; bosses authored).
  • mobIntro (global step): spider 0 · emberball 1 · goblin 5 · hound 10 · warden 15 · skeleton 20 · sentinel 25 · rocketeer 30 · bombardier 35. B1F1 = spider ONLY; emberball is the one within-biome intro (B1F2); after that exactly one new type per biome start. Boss/Gatekeeper/Nest stay outside.

2.2 Unity current state

  • Assets/Config/Enemies/*: spider 90/24 · emberball 80/14/26 (projDamage). Intro steps: spider 0, goblin 0, hound 1, emberball 2, skeleton 3, warden 5, rocketeer 7, bombardier 10; sentinel introStep 8 but rankAndFile = false (unused).
  • enemyScale.enemySpeedMul = 1.0 in GameConfig.asset (EnemyController.cs speed-parity line).
  • Burn: web-only value so far — locate the Unity burn tick (StatusEffects / projectile fire flag) and confirm where burnDmg lives before changing.

2.3 Port tasks

  1. Enemy_spider.asset: hp 50, atk 14. Enemy_emberball.asset: hp 40, atk 7, projDamage 6.
  2. introStep updates: spider 0 · emberball 1 · goblin 5 · hound 10 · warden 15 · skeleton 20 · sentinel 25 · rocketeer 30 · bombardier 35 — and set Enemy_sentinel.rankAndFile = true (the web schedule includes it; keep keykeeper and defense mobs as-is).
  3. GameConfig.asset enemyScale.enemySpeedMul → 0.5.
  4. Burn DoT → 1 (find the Unity site; if burn isn't ported yet, note it in the burn port).

3. Loot economy rework (guaranteed chest gear · one-tier upgrade · weapon gating · biome rarity tables)

3.1 Web rule — chest composition

Every real chest guarantees exactly ONE gear piece at its own tier; per-chest gear probability knobs are deleted (caseGearChance, maxGearPerChest, chest gearBias, vault guaranteeGear, the legendary-chest "first item legendary" rule).

rollGuaranteedGear(base):  rarity = (base < legendary && rand < loot.gearUpgradeChance /*0.20*/)
                                    ? base+1 : base;   return randomLoot(rarity);

randomLoot(rarity):  // slot pick is now WEAPON-GATED (loot.weaponEpicOnly = true):
  // weaponMelee/weaponRanged may roll ONLY at epic/legendary; common/rare picks from the six
  // NON-weapon slots. Authored mkItem calls (tutorial/starter) unaffected.

rollChestLoot(c):
  1. loot[0] = rollGuaranteedGear(c.rarity)                    // always
  2. fill to n = chestItems + legBonus + rand(0,1) + bonus:    // filler is STRICTLY non-gear
       rollLootItem(rollChestItemRarity(c.rarity), { noGear: true,
                    noValuable: c.rarity=="common" && !t1Valuables })   // common filler = blueprints
  3. coin / consumable bonus rolls  unchanged
  4. fixedLoot chests: if !crate && !barrel && !resourceOnly and the authored haul has NO gear,
     unshift(rollGuaranteedGear(c.rarity))

rollLootItem is now the non-chest path only (mob/bot drops; gearChance 0.06 × firstFloorGearBoost 2.5 on global step 0 — values unchanged).

Nest power-check bypass: damage() skips the powCheck power-ratio damping when target.nestDef && source === player — the player's real weapon damage applies to nests (§4).

Rival ranking (botLoot): chests ranked by RAR_LADDER.indexOf(rarity) (vault 5; keyRacer −1) instead of gearBias. (Rivals are parked in Unity — carry as a note.)

3.2 Web rule — per-biome container rarity tables

loot.chestRarityBiome1..10 = weights [Common, Rare, Epic, Legendary]:

B1 [.80 .20 0 0]  B2 [.40 .60 0 0]  B3 [.20 .70 .10 0]  B4 [.15 .65 .20 0]  B5 [.10 .60 .30 0]
B6 [.08 .52 .40 0] B7 [.06 .44 .50 0] B8 [.04 .36 .60 0] B9 [.03 .27 .70 0] B10 [.02 .18 .80 0]

applyBiomeChestRarities(chests, enemies, biome) runs once post-generation (skipped for scripted FTUE + Floor 0): every real chest (!crate && !barrel) re-rolls rarity from the biome table (rollBiomeChestRarity: weighted, total-normalized, clamp-to-last-table, fallback common) and every Nest re-rolls nestRarity from the same table. Generator depth-tier rarity becomes a fallback only.

3.3 Unity current state

  • Loot/LootRoller.cs is the complete roller port: RandomLoot (uniform slot pick, :52–88), RollLootItem (:122–127), RollChestItemRarity (:130–136), RollChestLoot (:139–160 — the OLD composition: maxGearPerChest quota, guaranteeGear vault flag, legendary first-item rule, gearBias param), RollMobDrop (:165–178).
  • World/Chest.cs:22–26,186–188 carries isVault/guaranteeGear/gearBiasall dead: nothing assigns them (grep-verified); warren cache chests roll plain gearChance, the FTUE vault chest is FixedLoot.
  • Chest rarity today = climb depth (MapPopulator.cs:194 for nest rooms, :265 for loot rooms).
  • GameConfig.loot has caseGearChance (dead) + maxGearPerChest; no gearUpgradeChance, weaponEpicOnly, or rarity tables.
  • No crate resourceOnly concept (crates/kegs are SpawnCrackable, MapPopulator.cs:150–164, with fixed one-blueprint / coin-or-blueprint hauls + timber fill).

3.4 Port tasks

  1. GameConfig.Loot: delete caseGearChance + maxGearPerChest; add gearUpgradeChance = 0.20, weaponEpicOnly = true, and the rarity tables — author as one serializable array of 10 rows (e.g. ChestRarityRow { float common, rare, epic, legendary; }[]) with clamp-to-last semantics, not ten named fields. Asset!
  2. LootRoller: add RollGuaranteedGear(baseRarity); gate the slot pick in RandomLoot (weaponEpicOnly && rarity < Epic → pick from the six non-weapon slots — add a GearSlotEx.NonWeaponOrder); rewrite RollChestLoot to the §3.1 composition (guaranteed piece first, filler noGear: true, common filler blueprint-only, drop the legendary-first and quota logic, drop the guaranteeGear/gearBias params).
  3. Chest.cs: remove the dead guaranteeGear/gearBias fields; add resourceOnly (used by Floor 0's timber crate, §6) and apply the fixed-loot gear-backfill rule in the open path: authored FixedLoot on a real chest (not crate/keg/resourceOnly) with no gear item gets RollGuaranteedGear(rarity) prepended.
  4. Biome rarity pass: at the end of MapPopulator.Populate, re-roll every spawned real chest's rarity and every nest's NestRarity from the biome table (use the raid's virtual biome index, clamped by the table). The depth-based ClimbFrac rarity stays as the pre-pass fallback, mirroring the web. Skip on Floor 0 / tutorial.
  5. Nest power-check bypass: in the damage damping site (the Unity powCheck port — combat/Health pipeline), skip the ratio clamp when the target IsNest and the attacker is the player.
  6. Note only (parked): rival chest ranking by rarity.

4. Nest rework (fixed per-biome HP · 3-gear payout)

4.1 Web rule

  • nestSpawnScale(biome): hp = nest.hpBiome1/2/3 (900/1500/2400) + hpLatePerBiome (400) × (biome−2) for B4+ (B10 = 5200); passed as {hp: fixed/authored, dmg: 1, noHpSponge: true} and — critically — replaces the whole mobScale, so the nest ignores floor danger AND the player-Power overRef term. A pure DPS/time gate (only gear damage growth speeds it up; combined with the §3.5 power-check bypass).
  • Payout: nest.valuables deleted → nest.gearCount = 3; death drops exactly three rollGuaranteedGear(nestRarity) pieces — NO coin/blueprint shower, NO flat blueprints += 1..3.
  • nestRarity from the shared biome table (§3.2).

4.2 Unity current state

  • No nest config group: tuning is EnemyDef.NestSettings (EnemyDef.cs:171–184; Enemy_nest.asset has valuablesMin 5 / valuablesMax 8, no gearCount, no hp curve).
  • Nest HP goes through the generic path: 900 × mobHpMult × (1+overRef·hpPerPow) × _raid.Danger (EnemyController.Start).
  • Death loot: EnemyController.cs:1450–1463 — 1 guaranteed gear (RollLootItem(rarity, gearChance: 1)) + 5–8 coin/blueprint shower + BlueprintsThisRaid += 1..3.

4.3 Port tasks

  1. NestSettings: remove valuablesMin/Max; add gearCount = 3, hpBiome1 = 900, hpBiome2 = 1500, hpBiome3 = 2400, hpLatePerBiome = 400 (update Enemy_nest.asset).
  2. Spawn scaling: when MapPopulator seeds a nest, compute the fixed HP for the (virtual) biome and pin it — the clean Unity mechanism is the existing rampHp override (rampHp = fixedHp / def.hp, noHpSponge = true) plus suppressing the overRef power term for nests: extend EnemyController.Start so a nest (or a new flatScale flag) skips (1 + overRef*hpPerPow) exactly like the web's replaced mobScale. rampDmg = 1.
  3. Death loot (EnemyController.cs:1450–1463): gearCount × RollGuaranteedGear(NestRarity); delete the shower + the direct blueprint grant.

5. Ten-biome campaign

5.1 Web rule

  • prog.biomeCount 3 → 10; stageSizes gets 7 new rows easing to the 120-tile cap; prog.bonusZonesMax = 4 caps the per-biome bonus zones.
  • ensureBiomeLadder(s) in load(): never truncate; APPEND {unlocked: i==0 || prev.cleared, cleared: false, stage: 1} until biomes.length == biomeCount; clamp save.biome.
  • Lobby becomes scrollable (web CSS — no direct port).

5.2 Unity current state

  • Biome count = BiomeSet.BiomeCount (3 authored: Thornwood/Sunken Mire/Ashen Deep, 5 floors each); BiomeSet.Biome(i) clamps to last; the endless virtual ladder already extends past authored content with endlessNames (12 entries) + extraRungs danger.
  • SaveService.Migrate (:85–104) grows biomes[] to a hardcoded const biomeCount = 3; MetaLoopController.EnsureLadder (~:205–213) appends at runtime.
  • stageSizes has no Unity analogue (warren rolls its own W/H; MapDefinition.size retired).
  • The lobby pager already handles any biome count (arrows), gated on unlocked.

5.3 Port tasks

  1. Campaign length: introduce the authored campaign target = 10 — since Unity derives count from BiomeSet, either (a) author 7 more BiomeDef entries that REUSE Biome_3's art/waves (content fills in later), or (b) add prog.biomeCount = 10 and teach the ladder/lobby to run on max(BiomeSet.BiomeCount, prog.biomeCount) with BiomeSet.Biome(i) clamp handling art. (a) is closer to the existing architecture (everything already keys off BiomeSet + clamps); pick one and keep Migrate/EnsureLadder consistent with it.
  2. Save ladder: replace the hardcoded const biomeCount = 3 in SaveService.Migrate with the resolved campaign count and add the web's unlock-next-if-previous-cleared rule to the append loop (check MetaLoopController.EnsureLadder — merge the two grow paths into one helper so load and runtime agree).
  3. Map size: stageSizes does NOT port (warren architecture). If late-biome maps should grow, express it as authored WarrenShapeOverride per BiomeDef/MapDefinition (widthMin/Max etc.) — flag as a design/content task, not a code port.
  4. bonusZonesMax: no port — zones were removed with the open-world cut.
  5. Lobby: verify the pager + StageIndicator row and MetaLoopController progression handle biome 4+ (they should — endless already does); update any hardcoded 3s.

6. Floor 0 — the fixed onboarding raid (FTUE)

6.1 Web rule — map (genZeroFloorMap, 40×64 tiles)

A fresh save's first raid is Biome 1 · Floor 0, ctx = {kind:"raid", biome:0, stage:0} — fully authored, deterministic:

bottom → top:
spawn room   (15,54)-(24,61) — EMPTY; spawn at tile (19,59)
loot room    (14,45)-(25,52) — ONE Common chest (19,48)                 POI "First Cache"
nest room    (13,35)-(26,43) — ONE Spider Nest (16,38), no brood        POI "Spider Nest"
timber room  (14,26)-(25,33) — ONE wooden crate (19,29): crate=true, resourceOnly=true,
                               fixedLoot = [60 timber]                  POI "Timber Crate · 60"
chamber      (10,7)-(29,22)  — the STANDARD defense-chamber contract (pad at padF fractions,
                               T/L/R doorways + aprons mirroring the generator, 3-wide S player door)
3-tile doors connect the rooms in a straight line; zero randomness/props. fixedLayout tag.

Floor rules: no round timer; no biome chest-rarity pass; standard fog; nest = production system at fixed B1 HP (900), nestRarity "common", leashed to its room; chest = production economy (⇒ guaranteed gear piece); crate = production crackable holding the exact 60-timber defense budget; defense finale = the SHARED system. mkDefMob first-floor softening covers Floor 0: firstFloor = biome==0 && (stage==1 || zeroFloor) × defense.firstFloorMobMul = 0.25 on hp+dmg (counts/speed/cadence untouched). The defense session does NOT advance on a Floor 0 win.

6.2 Web rule — safe corruption demo

At raid start Floor 0 spawns a demo front: {y: bottom, demo: true, warned: true, stopY: just short of the player (1.4 tiles), speed: 320 px/s}. updateCorruption early-returns on demo before every production effect (no slow, no DPS, no mob cleanup); drawCorruption renders a demo front even with corruption disabled; arrived latches at stopY (drives coach step 0).

6.3 Web rule — the coach (zeroGuide)

One objective at a time; run-state {step, t, pathT, path, target}. Seven Russian lines:

0 Остерегайся порчи — она наступает.                 (corruption demo)
1 Открой сундук и забери лут.                        (the gear chest)
2 Уничтожь гнездо.                                   (the Spider Nest)
3 Открой ящик и получи ресурс для строительства.     (the 60-timber crate)
4 Построй базовое здание.                            (the Base slot)
5 Построй защитную башню.                            (nearest UNLOCKED tower slot)
6 Запусти эвакуацию и защищайся от волн противников. (the extraction pad)

Completion predicates observe REAL state (zeroGuideStepDone): - 0: demo arrived AND line on screen ≥ 2.5 s. - 1 & 3: container opened AND emptied AND all its spilled items collected — spilled loot carries _source (the chest) via spillLoot(..., source); the check rejects unpicked ground items with that source (the player can't leave the intro gear/timber behind). - 2: nest dead · 4: Base lvl>0 && !dead · 5: ANY tower lvl>0 && !dead · 6: pad phase ≠ idle. - On completion: step++, screenshake 0.18, timers reset; after step 6 the coach clears.

Route + pointer: every 0.35 s re-run A player → target (append the literal target point if the path ends short); render as an animated dashed gold polyline (dash [7,11], crawling offset, glow, informational only — never writes the player's path). Above the target a pulsing gold pointer*: down-triangle + ellipse ring, ±5 px sine float, lift 48/58/70 px by target type, drawn over actors. Steps 1–6 only. Coach text uses the standard prompt; entry banner "Floor 0 · Training Route"; the rotating tip is suppressed.

6.4 Web rule — save/lobby flow

  • Save: zeroFloorCleared (fresh = false; legacy saves migrate to true), floorSel default 0.
  • selectedRaidFloor: fresh-save fallback = Floor 0 when biome==0 && !zeroFloorCleared, else the frontier; explicit floorSel==0 = Floor 0 (B1 only); else clamp 1..frontier.
  • Lobby: Biome 1's floor row gets a Floor 0 tile (always unlocked, replayable); B1 floors 1–5 LOCKED until zeroFloorCleared; biome label "Floor 0 pending"; Play label "▶ Biome 1 · Floor 0 · Tutorial". All launch paths route through selectedRaidFloor.
  • endRun Floor 0 branch: progress-portal win → zeroFloorCleared = true; biome = 0; floorSel = 1 ("Floor 1 unlocked."); safe-exit win → banks only; death → standard rules.

6.5 Unity current state

  • No floor-0 concept anywhere (grep-verified); no zeroFloorCleared; SaveData.floorSel exists but is 0-based (0 = Floor 1) — the web's floorSel==0 sentinel does NOT map 1:1.
  • Authored-map machinery exists but is FTUE-shaped: MapGenerator.BuildFromLayout (:468–516, border+rects+vault, web-frame FlipY) + MapPopulator.PopulateTutorial (:371–464). Neither builds a defense chamber/mouths — only WarrenGen.Carve does.
  • Corruption: CorruptionController has no demo mode; spawned only on chamber floors outside tutorial (RaidBootstrap.cs:152–162).
  • Coach: CoachView is live in the HUD (panel_tutorial, fed from RaidState.CoachText/Header by InGameViewController.cs:192–194).
  • Pointer/route: TutorialArrowView/TutorialArrow (billboard arrow, bob, fog rules) + PathVisualizer (dashed Shapes route for the PLAYER's own path — component-mode Shapes, pooled segments) are the closest building blocks; neither draws an arbitrary informational route yet.
  • Ground loot has no source-tracking (LootService.Spill).
  • Defense session advance: DefenseController.TickSpikes end → _save.Data.defense.session++.

6.6 Port tasks

  1. RaidContext/floor identity: introduce zeroFloor (RaidState flag) selected as "biome 0 + floor −1"? No — keep it explicit: add SaveData.zeroFloorCleared and represent the selection as a dedicated flag (e.g. SaveData.floorSel = -1 sentinel or a separate bool zeroFloorSelected) rather than overloading the 0-based floorSel. Recommendation: use floorSel = -1 + a SelectedRaidFloor() resolver in MetaLoopController mirroring the web's (fresh-save fallback → Floor 0; legacy migrate zeroFloorCleared = true in SaveService.Migrate).
  2. Map: new ZeroFloorGen (static, beside WarrenGen) emitting the standard MapData contract (tiles, spawn, Chamber WarrenRoom, Mouths T/L/R + S door, room list). ⚠ The Unity chamber is 20×17 interior / 22×19 outer (branch change) vs the web Floor 0's literal 20×16 tiles — do NOT copy the web tile coords for the chamber; derive chamber+mouths from GameConfig.Defense exactly like WarrenGen and stack the four authored route rooms below the S door (preserve room sizes/gaps; total map ≈ 40×65). RaidBootstrap: route zeroFloor to this generator; skip the biome rarity pass (§3.4) and the round timer; populate via a small dedicated path (one chest, one nest via the §4 fixed-HP seeding with NestRarity = Common and no pre-spawn brood, one timber crate crate + resourceOnly + FixedLoot[60 timber], no other content).
  3. Defense softening + session: add GameConfig.defense.firstFloorMobMul = 0.25 (asset!) and apply in the Unity defense-mob spawn (hp + atk only) when biome==0 && (floor==1 || zeroFloor); guard defense.session++ (DefenseController spikes-end) with !zeroFloor.
  4. Corruption demo: CorruptionController.InitDemo(stopZ, speed) — front surges at 320 px/s (÷ tile → world units) from the map's S edge and freezes ~1.4 tiles short of the spawn (Unity's front travels along +Z from below the fringe; the demo stop is behind the player, mirroring "rises from below the spawn"); skip slow/damage/cleanup and the stop-at-chamber logic; expose Arrived; render normally even if corruption.enabled is false. RaidBootstrap spawns the demo on zeroFloor (bypassing the !TutorialActive guard).
  5. Guide controller: new ZeroFloorGuideController (shape of TutorialController): the 7 lines (Russian, web parity — localization later) published through RaidState.CoachText; step targets resolved live (chest, nest, crate, Base slot, nearest unlocked tower slot via DefenseController, the pad) and the §6.3 predicates. Ground-item source tracking: add a Source reference to the spill path (LootService.Spill/GroundLoot) so steps 1/3 wait for full pickup; small AddShake(0.18) per completion.
  6. Route + pointer: a GuideRouteView — re-path every 0.35 s through Pathfinder (player → target, append target point), rendered as pooled dashed gold Shapes lines (PathVisualizer is the template: same component-mode pooling, ground-decal render queue, marching dash offset); pointer = extend TutorialArrow (it already floats/bobs/billboards) with the gold triangle+ring look, lift per target type, drawn above sprites.
  7. Lobby: LobbyView.RenderStages — prepend a Floor 0 pip on Biome 1 (its own template or the existing StageIndicator with a "T"/0 label); lock B1 floors 1–5 until zeroFloorCleared; pass-through in LobbyController.OnStage/OnDescend for the sentinel; stage label text ("Floor 0 · Tutorial" — the static "Raid" button label can stay, the floor readout carries it).
  8. Raid end: in the progression banking path (MetaLoopController/result flow): Floor 0 + progress-portal win → zeroFloorCleared = true; biome = 0; floorSel = 0 (floor 1); note text; safe-exit banks only; no ladder advance either way.
  9. Entry banner "Floor 0 · Training Route" (Floaters.ShowBanner at raid start).

7. Save migrations

Web ships three one-shot localStorage TUNING-snapshot migrations (thornwood_balance_new_v1, _poi_gate_v1, _ten_biomes_v1) releasing stale config overrides — no Unity analogue (tuning lives in the .asset). The SAVE migrations that do port (all into SaveService.Migrate):

Web Unity task
ensureBiomeLadder — append to campaign count, unlocked = i==0 || prev.cleared, clamp biome replace the hardcoded const biomeCount = 3; unify with MetaLoopController.EnsureLadder
legacy saves: zeroFloorCleared === undefined → true same rule when the field lands
fresh defaults zeroFloorCleared:false, Floor 0 selection SaveData defaults + SelectedRaidFloor fallback

8. Explicitly NOT ported

  • prog.stageSizes rows / bonusZonesMax — no Unity consumers (warren sizing / zones removed). Late-biome map growth = optional WarrenShapeOverride authoring (§5.3).
  • Web #menu scroll CSS — the Unity pager lobby doesn't need it.
  • Tuning-snapshot migrations (§7).
  • Rival chest-ranking change — rivals parked.
  • PROJECT_CONTEXT.md prose — web-repo docs.

9. Suggested port order

  1. §1 curves + §2 retune (config/assets + Progression helpers + EnemyController seams) — smallest, unblocks balance testing.
  2. §3 loot economy + rarity tables (LootRoller/Chest/MapPopulator pass).
  3. §4 nest (needs RollGuaranteedGear + the flat-scale seam).
  4. §5 ten biomes (BiomeSet entries or count override + save ladder unification).
  5. §6 Floor 0 (needs §3 crate flags, §4 nest seeding, §5 floor selection, the defense firstFloorMobMul + session guard).
  6. Audit: line-by-line vs the cited web functions; play Floor 0 → B1F1 → B2 boundary (no danger dip) → nest payout → chest rarity distribution sanity across biomes.