---
id: wiki-2026-0508-2026-04-25-skybound-vampire-surv
title: Skybound — Vampire Survivors Loop and Stage Curve Preparation
category: 10_Wiki/Topics
status: verified
canonical_id: self
aliases: [Skybound VS Loop, Stage Curve Tuning]
duplicate_of: none
source_trust_level: A
confidence_score: 0.9
verification_status: applied
tags: [skybound, gamedev, vampire-survivors, stage-curve, balance]
raw_sources: []
last_reinforced: 2026-05-10
github_commit: pending
tech_stack:
  language: typescript
  framework: phaser3
---

# Skybound — Vampire Survivors Loop and Stage Curve Preparation

## 매 한 줄
> **"매 VS-style loop 의 핵심은 spawn pressure curve × pickup economy 의 monotonic escalation"**. 매 Skybound 의 4월 25일 작업은 stage curve, enemy spawn rate, XP gem drop ratio 의 baseline 을 8-stage progression 에 맞춰 재설계 한 작업. 매 Phaser 3 + TypeScript stack, vampire-survivors-like (VSL) genre.

## 매 핵심

### 매 VSL Loop 의 4 Phase
- **Phase 1 (0-2m)**: low density, single enemy type — player 의 control familiarization.
- **Phase 2 (2-5m)**: pack spawns + first elite — pickup economy 시작.
- **Phase 3 (5-8m)**: mixed waves + miniboss — build identity 확정.
- **Phase 4 (8m+)**: screen-fill density + boss — power fantasy peak.

### 매 Stage Curve Variables
- `enemyDensity(t)` — concurrent on-screen enemies.
- `enemyHP(t)` — per-unit HP scaling.
- `xpGemValue(t)` — pickup denomination growth.
- `eliteRatio(t)` — chance of elite spawn per wave.

### 매 응용
1. 매 stage 별 4 phase 의 timing 을 data table 로 외부화.
2. 매 curve 의 monotonic 함수 (linear/exp/step) 를 designer 가 tweak.
3. 매 phase transition 에 audio/visual cue (screen flash, BGM layer add).

## 💻 패턴

### Stage Curve Definition
```typescript
type StageCurve = {
  stageId: number;
  durationSec: number;
  phases: Phase[];
};

type Phase = {
  startSec: number;
  endSec: number;
  enemyDensity: (t: number) => number;
  enemyHpMul: (t: number) => number;
  xpGemValue: number;
  eliteRatio: number;
};

const STAGE_1: StageCurve = {
  stageId: 1,
  durationSec: 600,
  phases: [
    { startSec: 0, endSec: 120, enemyDensity: (t) => 5 + t * 0.05, enemyHpMul: () => 1, xpGemValue: 1, eliteRatio: 0 },
    { startSec: 120, endSec: 300, enemyDensity: (t) => 15 + t * 0.1, enemyHpMul: (t) => 1 + t * 0.002, xpGemValue: 2, eliteRatio: 0.05 },
    { startSec: 300, endSec: 480, enemyDensity: (t) => 30 + t * 0.15, enemyHpMul: (t) => 1.5 + t * 0.003, xpGemValue: 5, eliteRatio: 0.15 },
    { startSec: 480, endSec: 600, enemyDensity: () => 80, enemyHpMul: () => 3, xpGemValue: 10, eliteRatio: 0.3 },
  ],
};
```

### Spawner Loop
```typescript
class StageSpawner {
  constructor(private curve: StageCurve, private scene: Phaser.Scene) {}

  update(elapsedSec: number): void {
    const phase = this.curve.phases.find(p => elapsedSec >= p.startSec && elapsedSec < p.endSec);
    if (!phase) return;

    const localT = elapsedSec - phase.startSec;
    const targetCount = phase.enemyDensity(localT);
    const current = this.scene.enemies.countActive();
    const deficit = Math.floor(targetCount - current);

    for (let i = 0; i < deficit; i++) {
      const isElite = Math.random() < phase.eliteRatio;
      this.spawnEnemy({ hpMul: phase.enemyHpMul(localT), elite: isElite });
    }
  }
}
```

### XP Pickup Magnetism
```typescript
const PICKUP_RADIUS = 80;
const MAGNET_SPEED = 400;

function updatePickups(player: Player, gems: Gem[], dt: number): void {
  for (const gem of gems) {
    const dx = player.x - gem.x;
    const dy = player.y - gem.y;
    const dist = Math.hypot(dx, dy);
    if (dist < PICKUP_RADIUS || gem.attracted) {
      gem.attracted = true;
      const inv = 1 / Math.max(dist, 1);
      gem.x += dx * inv * MAGNET_SPEED * dt;
      gem.y += dy * inv * MAGNET_SPEED * dt;
      if (dist < 8) {
        player.gainXp(gem.value);
        gem.destroy();
      }
    }
  }
}
```

### Wave Director
```typescript
type Wave = { atSec: number; pattern: 'circle' | 'line' | 'pack' | 'elite'; count: number };

const WAVES_STAGE_1: Wave[] = [
  { atSec: 30, pattern: 'pack', count: 8 },
  { atSec: 90, pattern: 'circle', count: 12 },
  { atSec: 180, pattern: 'line', count: 20 },
  { atSec: 300, pattern: 'elite', count: 1 },
];

class WaveDirector {
  private fired = new Set<number>();
  tick(elapsedSec: number, waves: Wave[]) {
    for (const w of waves) {
      if (elapsedSec >= w.atSec && !this.fired.has(w.atSec)) {
        this.fired.add(w.atSec);
        this.spawnWave(w);
      }
    }
  }
}
```

### Phase Transition Cue
```typescript
scene.events.on('phase:enter', (phase: Phase) => {
  scene.cameras.main.flash(200, 255, 255, 255, false);
  scene.bgm.addLayer(`tier-${phase.tier}`);
  scene.add.text(W/2, 100, `WAVE ${phase.tier}`, { fontSize: '48px' }).setOrigin(0.5);
});
```

## 매 결정 기준
| 상황 | Approach |
|---|---|
| Solo dev, fast iteration | Data-table 외부화 + hot reload |
| Difficulty too spiky | Smooth curve (linear), avoid step |
| Pickup economy 초과 | Gem radius 축소 + value 인하 |
| Phase boundary unclear | Visual+audio cue 강화 |

**기본값**: 매 4-phase per stage, monotonic exp curve, designer-tunable JSON.

## 🔗 Graph

## 🤖 LLM 활용
**언제**: stage curve tuning, balance spreadsheet 작성, spawn pattern enumeration.
**언제 X**: per-frame perf-critical hot path, GPU shader logic.

## ❌ 안티패턴
- **Hardcoded curve in code**: tweak 마다 rebuild — JSON/CSV 외부화 의 X.
- **Step function spike**: phase 전환 시 difficulty cliff — smooth curve 의 사용.
- **Pickup vacuum 너무 큼**: player 무이동 → economy 붕괴.
- **Elite ratio fixed**: phase progression 의 lost — ramp 의 사용.

## 🧪 검증 / 중복
- Verified (Vampire Survivors postmortem, Brotato dev blog).
- 신뢰도 A.

## 🕓 Changelog
| 날짜 | 변경 |
|---|---|
| 2026-05-08 | Phase 1 |
| 2026-05-10 | Manual cleanup — VSL loop + stage curve baseline 정리 |
