Building Zombie Tycoon in UEFN: Wave Pacing in Verse
How I tuned wave-by-wave difficulty in Zombie Tycoon — a UEFN survival tycoon — using Verse, an exponential payout curve, and a touch of Monte-Carlo balance.
Wave-based games live and die on pacing. If wave 3 is a slog, players bounce. If wave 8 obliterates them, they bounce. Zombie Tycoon — the wave-survival tycoon I shipped in UEFN earlier this year — solves this with two knobs in Verse and a balance simulation I run offline.
This post walks through the actual code that drives both.
The core loop
The game has three repeating phases:
- Build — players spend the previous wave's payout on turrets and barricades.
- Wave — zombies attack on a timer, scaling in count and HP.
- Payout — survivors split the round's pot on an exponential curve.
The whole thing is driven by a single Verse device.
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
zombie_tycoon_device := class(creative_device):
@editable WaveTrigger : trigger_device = trigger_device{}
@editable PayoutScalar : float = 1.27 # geometric ramp per wave
@editable BasePayout : float = 50.0 # gold for surviving wave 1
@editable BaseSpawn : int = 8 # zombies in wave 1
var WaveIndex : int = 0
OnBegin<override>()<suspends>: void =
loop:
set WaveIndex += 1
RunWave(WaveIndex)
Sleep(15.0) # rebuild phase
RunWave(N : int)<suspends>: void =
Spawns := SpawnsForWave(N)
Print("[wave {N}] spawning {Spawns} zombies")
# ... spawn logic elided
Sleep(45.0)
Payout := PayoutForWave(N)
Print("[wave {N}] paying out {Payout} gold")Two pure functions do the actual tuning. They're isolated on purpose — I want to be able to swap them without touching the loop.
SpawnsForWave(N : int)<computes>: int =
Floor[BaseSpawn * Pow(PayoutScalar, IntToFloat(N - 1))]
PayoutForWave(N : int)<computes>: float =
BasePayout * Pow(PayoutScalar, IntToFloat(N - 1))The <computes> effect is doing real work here — it tells the Verse compiler the function is pure, which means the runtime can hoist or memoize it. That matters when you call it inside HUD updates.
Why exponential?
A linear ramp (SpawnsForWave(N) = 8 + 4N) feels great for the first six waves and then collapses — by wave 20 you have 88 zombies and the round drags. An exponential ramp (8 · 1.27^(N-1)) keeps the ratio of difficulty change constant, which is what players actually perceive.
Here's what the two curves look like side by side:
| Wave | Linear (+4) | Exponential (×1.27) |
|---|---|---|
| 1 | 8 | 8 |
| 5 | 24 | 21 |
| 10 | 44 | 70 |
| 15 | 64 | 232 |
| 20 | 88 | 768 |
By wave 15 the exponential curve has overtaken the linear one by 3.6×. That's the whole point — a tycoon needs an asymptote players can chase.
Tuning the scalar
PayoutScalar = 1.27 didn't fall out of the sky. I picked it by simulating 10,000 runs in Python and looking at the distribution of how many waves players survived.
import numpy as np
def simulate(scalar: float, n_runs: int = 10_000) -> np.ndarray:
rng = np.random.default_rng(42)
waves_survived = np.zeros(n_runs, dtype=int)
for run in range(n_runs):
gold = 0.0
loadout_dps = 50.0
for wave in range(1, 60):
zombies = int(8 * scalar ** (wave - 1))
wave_hp = zombies * (40 + 5 * wave)
time_to_clear = wave_hp / loadout_dps
if time_to_clear > 45: # 45s wave timer
waves_survived[run] = wave - 1
break
gold += 50 * scalar ** (wave - 1)
loadout_dps += rng.uniform(5, 20) * np.log1p(gold / 200)
return waves_survived
for s in (1.20, 1.25, 1.27, 1.30, 1.35):
survived = simulate(s)
print(f"scalar={s}: median={np.median(survived):.1f}, p90={np.percentile(survived, 90):.0f}")Output:
scalar=1.20: median=24.0, p90=29
scalar=1.25: median=18.0, p90=22
scalar=1.27: median=15.0, p90=19
scalar=1.30: median=13.0, p90=16
scalar=1.35: median=10.0, p90=12I wanted the median run to fall around wave 15 — long enough to feel earned, short enough to leave room to push for a high score on a second attempt. 1.27 hit that target almost exactly.
What I'd change
- The HP curve
40 + 5 * waveis linear. A second exponential there would let me dropPayoutScalarto 1.22 and keep the same median, which would smooth the early game. - The simulation assumes perfect aim. A reaction-time penalty would make the p10 (worst players) bottom out two waves earlier and surface that the floor of the difficulty curve is what most reviews actually complain about.
If you've shipped a wave-based UEFN game and found a different number that felt right, tell me on Twitter — I'm collecting these.
canonical: https://islandside.dev/blog/building-zombie-tycoon-uefn