Skip to content
back to writing
Game Dev

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.

4 min readUEFNVerseGame BalanceZombie Tycoon

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:

  1. Build — players spend the previous wave's payout on turrets and barricades.
  2. Wave — zombies attack on a timer, scaling in count and HP.
  3. Payout — survivors split the round's pot on an exponential curve.

The whole thing is driven by a single Verse device.

zombie_tycoon.verse
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.

curves.verse
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:

WaveLinear (+4)Exponential (×1.27)
188
52421
104470
1564232
2088768

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.

balance.py
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=12

I 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 * wave is linear. A second exponential there would let me drop PayoutScalar to 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