Combat Loop Pacing: Designing Tension-Relief Cycles for Roguelike Encounters

Who this helps: Roguelike combat system designers, technical leads who inherited a difficulty curve but still hear "combat feels repetitive" in feedback, and system programmers working in Phaser, Unity, Godot, or Unreal who need a reusable rhythm pattern across engines.

---

The Problem

You built a difficulty curve. Rooms get harder. Bosses scale. And yet players say every fight feels the same. Health bars drop, tension spikes for a moment, then the rest of the corridor feels like cleanup. Only the boss room gets their pulse up.

The core issue: Fun in combat does not come from the difficulty level — it comes from the rhythm of difficulty changes. Csikszentmihalyi's flow theory shows that immersion emerges from a dynamic balance between skill and challenge. In roguelikes, that balance must be maintained not just at the macro scale (across a whole run) but at the micro scale — inside each individual combat loop.

SymptomRoot Cause
Flat combat — every room feels identicalNo tension variation
Spring tension — difficulty only climbs until burnoutNo recovery windows
Relief overflow — rewards and heals too frequentTension segments too short or weak
Predictable rhythm — "always 3 easy, 1 hard"Fixed-period patterns
HP-gate collapse — tension only near deathSole tension source is HP

---

Why It Matters

---

Core Principle

Model the combat loop as a tension curve, then design the curve's shape using four building blocks: Baseline, Spike, Recovery, and Transition.

Tension is not just "low HP." Multiple independent sources contribute — resource depletion, spatial pressure, time constraints, information scarcity, simultaneous threats. Track each one separately and sum them.

     ___spike___          ___spike___
    /           \        /           \
___/             \______/             \______
  baseline       recovery  baseline      recovery

The Four Building Blocks

BlockRoleDesign Variables
BaselineMaintain ambient tensionEnemy count, attack frequency, threat level
SpikeMaximum-tension momentBoss/elite spawn, simultaneous threats
RecoveryRelease tensionEnemy clear gap, healing item, safe zone
TransitionConnect blocksTelegraphing (warning), audio cues, spawn delay

Diversifying Tension Sources

SourceExampleDuration
Resource depletionCooldown locked, ammo goneSeconds
Spatial pressureNarrowing arena10 seconds
Time constraintTimer, gradual spawn30 seconds
Information scarcityFog-of-war enemiesMid-combat
Multiple threatsMelee + ranged simultaneousSeconds

When tension has only one source (usually HP), players only feel engaged at the edge of death. Add two or more independent sources and the entire combat loop becomes engaging.

---

How To Apply It

Step 1: Build a Tension Tracker

Track cumulative tension from multiple sources with natural decay:

class TensionTracker {
  constructor(config = {}) {
    this.value = 0;
    this.sources = new Map();
    this.decayRate = config.decayRate || 0.02;
    this.history = [];
  }

  addSource(name, contribution) {
    this.sources.set(name, Math.min(1, contribution));
    this._recalculate();
  }

  removeSource(name) {
    this.sources.delete(name);
    this._recalculate();
  }

  _recalculate() {
    let sum = 0;
    for (const c of this.sources.values()) sum += c;
    this.value = Math.min(1, sum);
  }

  update(deltaMs) {
    const decay = this.decayRate * (deltaMs / 1000);
    this.value = Math.max(0, this.value - decay);
  }

  getPhase() {
    if (this.value < 0.25) return 'recovery';
    if (this.value < 0.55) return 'baseline';
    if (this.value < 0.80) return 'escalation';
    return 'spike';
  }
}

Step 2: Build an Encounter Pacer

Use the tension phase to decide what comes next:

class EncounterPacer {
  constructor(tensionTracker, config = {}) {
    this.tension = tensionTracker;
    this.minBaselineBeforeSpike = config.minBaselineBeforeSpike || 2;
    this.maxBaselineBeforeSpike = config.maxBaselineBeforeSpike || 5;
    this.spikeDuration = config.spikeDuration || 15;
  }

  decideNextEncounter(roomsSinceLastSpike) {
    const phase = this.tension.getPhase();

    if (roomsSinceLastSpike >= this.maxBaselineBeforeSpike) {
      return { type: 'spike', difficulty: 'hard', duration: this.spikeDuration };
    }

    if (phase === 'spike') {
      return { type: 'recovery', difficulty: 'easy' };
    }

    if (roomsSinceLastSpike >= this.minBaselineBeforeSpike) {
      const spikeChance = (roomsSinceLastSpike - this.minBaselineBeforeSpike)
                       / (this.maxBaselineBeforeSpike - this.minBaselineBeforeSpike);
      if (Math.random() < spikeChance * 0.4) {
        return { type: 'spike', difficulty: 'hard', duration: this.spikeDuration };
      }
    }

    return { type: 'baseline', difficulty: 'normal' };
  }
}

Step 3: Choose a Run-Level Curve Shape

The macro curve determines how baseline tension scales across the entire run:

class RunPacer {
  getTargetTension(t) {
    switch (this.curveType) {
      case 'ascending': return Math.pow(t, 1.5);
      case 'mountain':  return Math.sin(Math.PI * t);
      case 'step':      return Math.floor(t * 4) / 4;
      case 'wave':      return Math.sin(3 * Math.PI * t) * 0.5 + 0.5 * t;
      case 'spike':     return Math.pow(t, 3);
      default:          return t;
    }
  }
}
Curve TypeFeelBest For
AscendingSteady ramp to climaxShort runs, arcade feel
MountainPeak mid-run, taper end30-minute sessions
StepDiscrete difficulty tiersStage-based games
WaveOscillating intensityLong runs with varied pacing
SpikeExponential late-game spikeHigh-stakes climax design

Step 4: Engine Integration

Phaser 3 example:

class CombatScene extends Phaser.Scene {
  create() {
    this.tensionTracker = new TensionTracker({ decayRate: 0.03 });
    this.encounterPacer = new EncounterPacer(this.tensionTracker, {
      minBaselineBeforeSpike: 2,
      maxBaselineBeforeSpike: 5
    });
    this.runPacer = new RunPacer('wave');

    this.events.on('enemy-spawned', () => {
      const density = this.enemies.getLength() / 10;
      this.tensionTracker.addSource('enemy_density', density);
    });
  }

  update(time, delta) {
    this.tensionTracker.update(delta, time);
    const adjustment = this.runPacer.getPacingAdjustment(
      this.tensionTracker.value,
      this.getRunProgress()
    );
    if (adjustment > 0.2 && this.spawnTimer <= 0) {
      this.spawnEnemyTier('hard');
    } else if (adjustment < -0.3) {
      this.throttleSpawns();
    }
  }
}

Unity (C#) notes: Use a TensionService as a ScriptableObject with AddSource(string id, float value) and GetPhase() methods. Hook into OnTriggerExit for spatial pressure release and Update() for decay.

Godot: Implement as an autoloaded TensionManager with signals tension_phase_changed(Phase) and tension_spiked(). Connect encounter spawners to these signals.

Unreal: Create a UTensionComponent with UTensionSubsystem for run-level tracking. Use FTimerHandle for decay ticking and UBlueprintFunctionLibrary for designer-facing nodes.

---

Common Mistakes

MistakeSymptomFix
Forced recovery — guaranteed safe room after every spikePlayers learn the pattern; tension evaporatesKeep minor threats active during recovery
Predictable period — always 3 easy then 1 hardPlayers recognize the loop and disengageAdd probabilistic variation to spike timing
HP-as-sole-tension-sourcePlayers only care about combat at low healthAdd spatial, resource, and time pressure sources
Over-telegraphingSpoils surprise; tension never buildsUse partial cues, not explicit warnings
Ignoring run-level pacingMicro-rhythms are good but the run feels monotonousLayer macro curves on top of micro patterns

---

Real-World Examples

---

Checklist

---

References

  1. Csikszentmihalyi, M. Flow: The Psychology of Optimal Experience. Harper & Row, 1990.
  2. Schell, Jesse. The Art of Game Design (2nd ed.). CRC Press, 2019. Chapter 11 — Balance and Flow.
  3. Greg Kasavin. "Hades: Creating a Mythological Roguelike." GDC 2021. Supergiant Games.
  4. Motion Twin. "Dead Cells: Rhythm and Combat Design." GDC 2019.
  5. Derek Yu. "Designing Spelunky." GDC 2017.
  6. Straub, Marty. "DOOM (2016) Combat Design." GDC 2017. id Software.
  7. Hopoo Games. "Risk of Rain 2 scaling analysis." 2020.

---