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.
| Symptom | Root Cause |
|---|---|
| Flat combat — every room feels identical | No tension variation |
| Spring tension — difficulty only climbs until burnout | No recovery windows |
| Relief overflow — rewards and heals too frequent | Tension segments too short or weak |
| Predictable rhythm — "always 3 easy, 1 hard" | Fixed-period patterns |
| HP-gate collapse — tension only near death | Sole tension source is HP |
---
Why It Matters
- Sustained engagement: Rhythmic tension shifts keep players in flow. Without rhythm, players bore in 5 minutes or burn out in 10.
- Run diversity: Change the shape of the tension curve and the same combat system produces a different-feeling run every time.
- Balance objectification: When you can quantify "this room is boring" as a tension value, balance decisions become data-driven instead of gut-feel.
- Player retention: A reward placed after a tension spike triggers the "one more run" impulse far more reliably than evenly distributed loot.
---
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
| Block | Role | Design Variables |
|---|---|---|
| Baseline | Maintain ambient tension | Enemy count, attack frequency, threat level |
| Spike | Maximum-tension moment | Boss/elite spawn, simultaneous threats |
| Recovery | Release tension | Enemy clear gap, healing item, safe zone |
| Transition | Connect blocks | Telegraphing (warning), audio cues, spawn delay |
Diversifying Tension Sources
| Source | Example | Duration |
|---|---|---|
| Resource depletion | Cooldown locked, ammo gone | Seconds |
| Spatial pressure | Narrowing arena | 10 seconds |
| Time constraint | Timer, gradual spawn | 30 seconds |
| Information scarcity | Fog-of-war enemies | Mid-combat |
| Multiple threats | Melee + ranged simultaneous | Seconds |
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 Type | Feel | Best For |
|---|---|---|
| Ascending | Steady ramp to climax | Short runs, arcade feel |
| Mountain | Peak mid-run, taper end | 30-minute sessions |
| Step | Discrete difficulty tiers | Stage-based games |
| Wave | Oscillating intensity | Long runs with varied pacing |
| Spike | Exponential late-game spike | High-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
| Mistake | Symptom | Fix |
|---|---|---|
| Forced recovery — guaranteed safe room after every spike | Players learn the pattern; tension evaporates | Keep minor threats active during recovery |
| Predictable period — always 3 easy then 1 hard | Players recognize the loop and disengage | Add probabilistic variation to spike timing |
| HP-as-sole-tension-source | Players only care about combat at low health | Add spatial, resource, and time pressure sources |
| Over-telegraphing | Spoils surprise; tension never builds | Use partial cues, not explicit warnings |
| Ignoring run-level pacing | Micro-rhythms are good but the run feels monotonous | Layer macro curves on top of micro patterns |
---
Real-World Examples
- Hades (Supergiant Games): Alternates high-density combat rooms with narrative "breathing" encounters. The conference room and character interactions serve as designed recovery, not just empty space.
- Dead Cells (Motion Twin): Uses a "hot/cold" room system where intensity varies organically. Biome transitions act as natural transitions between curve segments.
- DOOM (2016): Encounter design follows a strict spike → resource-drop → baseline pattern. Glory kills during recovery maintain engagement without adding threat.
- Spelunky HD (Derek Yu): Tension comes from environmental hazards (falling blocks, arrow traps) as much as enemies, creating multi-source pressure that never fully dissipates.
- Risk of Rain 2 (Hopoo Games): The director system allocates points over time, creating ascending tension with intermittent elite-spike moments. Scaling ensures the curve never plateaus.
---
Checklist
- [ ] I track tension from at least 3 independent sources (not just HP)
- [ ] My encounter pacer has configurable min/max baseline rooms before spikes
- [ ] Recovery windows still contain minor threats (not zero-danger rooms)
- [ ] Spike timing has probabilistic variation, not fixed periods
- [ ] I chose a run-level curve shape that matches my target session length
- [ ] Tension sources decay naturally over time (no permanent max-tension states)
- [ ] Each engine integration uses native event systems (not polling where avoidable)
- [ ] I can answer "what phase is the player in right now?" at any point in combat
---
References
- Csikszentmihalyi, M. Flow: The Psychology of Optimal Experience. Harper & Row, 1990.
- Schell, Jesse. The Art of Game Design (2nd ed.). CRC Press, 2019. Chapter 11 — Balance and Flow.
- Greg Kasavin. "Hades: Creating a Mythological Roguelike." GDC 2021. Supergiant Games.
- Motion Twin. "Dead Cells: Rhythm and Combat Design." GDC 2019.
- Derek Yu. "Designing Spelunky." GDC 2017.
- Straub, Marty. "DOOM (2016) Combat Design." GDC 2017. id Software.
- Hopoo Games. "Risk of Rain 2 scaling analysis." 2020.
---