Verse Performance Patterns: Writing UEFN Code That Doesn't Tank Your TPS
Idioms, anti-patterns, and architectural choices that decide whether your Verse code hums at 30 ticks per second or chokes the moment a few players show up. Event-driven design, async discipline, and the data-flow patterns that scale.
The performance ceiling is set by your architecture, not your loops
A common surprise for creators coming to Verse from C# or TypeScript is that the language is fast enough, the engine is fast enough, and the tick budget is generous, and yet their map still drops to single-digit frames the moment ten people join. The reason is almost never a slow function. It's an architecture that does too much work, in the wrong place, at the wrong cadence.
Verse rewards a specific style: event-driven, data-oriented, asynchronous-first. The idioms below are the ones we reach for when we want a UEFN map to behave the same way at one player as it does at sixteen.
Stop polling. Subscribe.
The single biggest performance mistake in Verse code is reaching for Sleep(0.1) and a loop when an event already exists. A polling loop runs every tick whether anything has changed or not. An event subscription runs only when the thing you care about happens.
A polling pattern that looks innocent:
WatchPlayerHealth(Agent : agent) : void = {
loop:
Sleep(0.5)
if (Player := Agent.GetFortCharacter[]):
Health := Player.GetHealth()
if (Health < 25.0):
ApplyLowHealthEffect(Agent)
}
This burns cycles forever even when the player is at full health, and it scales linearly with the player count. The event-driven version uses Damaged.Subscribe and only fires when the value actually changes:
WatchPlayerHealth(Agent : agent) : void = {
if (Player := Agent.GetFortCharacter[]):
Player.DamagedEvent().Subscribe(OnDamaged)
}
OnDamaged(Result : damage_result) : void = {
if (Char := damage_result.Target?, Health := Char.GetHealth(), Health < 25.0):
ApplyLowHealthEffect(Char.GetAgent[])
}
The rule of thumb: if the engine emits an event for the thing you're watching, subscribe to it. Movement, damage, elimination, item pickup, zone enter/exit, vehicle entry, all of these have signals. Reach for Sleep only when you genuinely need a periodic tick (a heartbeat, a respawn timer, a wave countdown), and even then prefer the longest interval that still feels responsive.
The cost of spawn and how to think about it
spawn is one of Verse's most powerful tools and one of its most abused. Every spawn allocates a coroutine, schedules it, and persists until it returns or is canceled. Spawn ten thousand of them across a session and you have ten thousand coroutines competing for tick time.
A pattern that quietly destroys performance:
OnPlayerJoined(Agent : agent) : void = {
spawn { TrackPlayerStats(Agent) }
spawn { TrackPlayerInventory(Agent) }
spawn { TrackPlayerLocation(Agent) }
spawn { TrackPlayerScore(Agent) }
spawn { TrackPlayerCosmetics(Agent) }
# ... ten more
}
Each of those is probably a polling loop in disguise. The fix is twofold: subscribe to the events instead of spawning watchers, and batch coroutines when you do need them. One coroutine with a race or sync block doing five things is far cheaper than five coroutines each doing one thing.
A useful mental model: a coroutine is a task, not a thread. You wouldn't spin up a thread to wait for a button press, and you shouldn't spawn a coroutine to wait for a damage event either. Reserve spawn for genuinely independent, long-lived asynchronous work like timed events, async network operations, or animation sequences.
Race, sync, and the block you're probably not using
Verse's structured concurrency primitives (race, sync, block, branch) are how you get clean async code without ten coroutines fighting each other. The one most creators forget about is race, which runs multiple async operations and returns the first one that completes.
The classic use case is "wait for X, but bail out if Y happens first":
WaitForPickupOrTimeout(Pickup : item_pickup_device, Timeout : float) : logic = {
race:
block:
Pickup.ItemPickedUpEvent.Await()
return true
block:
Sleep(Timeout)
return false
}
That single function captures what would otherwise be a tangle of state flags and polling. sync is its complement: run multiple async operations and wait for all of them. Both let you express temporal logic declaratively, which the engine can schedule far more efficiently than hand-rolled state machines.
branch is the one you reach for when you want fire-and-forget without blocking the parent flow. Use it sparingly, and only when the branch is genuinely independent. A branch that needs to communicate back is almost always better expressed with a spawn plus an explicit completion event.
Data-oriented design: stop putting state on devices
Devices in UEFN look like nice object-oriented homes for state. They are not. Every property you read from a device incurs an engine round-trip, and many of them are surprisingly expensive. Reading transform data, fetching components, and querying device state are all measurable in a way that "it's just a getter" suggests they shouldn't be.
A pattern that surfaces in almost every map we audit:
GetPlayerTeamColor(Agent : agent) : color = {
Devices := AllTeamColorDevices # 16 devices, one per team
for (Device : Devices):
if (Device.IsAssignedToTeam[Agent]):
return Device.GetColor()
return DefaultColor
}
Called once per player per tick, this loop dominates the frame. The fix is to build a cache once when team assignments change and read from the cache thereafter:
var TeamColorCache : [agent]color = map{}
OnTeamAssigned(Agent : agent, Team : team) : void = {
set TeamColorCache[Agent] = TeamColors[Team.GetTeamIndex()]
}
GetPlayerTeamColor(Agent : agent) : color = {
if (Color := TeamColorCache[Agent]):
return Color
return DefaultColor
}
The general principle: derive once, read often. Anything that's expensive to compute should be computed when its inputs change and stored in a Verse-native data structure (a map, an array, a struct field). Devices are an interface to the engine, not a database. Treat them that way.
The cost of failure
Verse's failure expressions (if (X := ...):, Y[], decides) are cheap when they succeed and not free when they fail. A function declared decides allocates a transactional context, runs the body, and rolls back state if the body fails. That rollback is fast, but it isn't free, and code that fails-then-recovers in a tight loop is measurable.
A pattern to be aware of:
TryAddPlayerToTeam(Agent : agent, TeamId : int) : void = {
if (Char := Agent.GetFortCharacter[], Team := AllTeams[TeamId]):
Team.Assign(Char)
}
Called for every joining player, the [TeamId] indexing might fail, the GetFortCharacter[] might fail, and each failure rolls back. That's fine in normal flow. But if your code is shaped so that failures are the common case (for example, you're scanning a large array and most entries don't match), restructure so the common path is success:
# Instead of: scan everything, fail-recover-fail-recover
for (Index -> Item : Items):
if (Item.IsActive[]):
ProcessItem(Item)
# Prefer: filter once, iterate the active set
ActiveItems := Items.Filter(IsActiveItem)
for (Item : ActiveItems):
ProcessItem(Item)
This is a small thing per call and a large thing in aggregate, especially in functions called every tick or per-player.
Network thinking: what crosses the wire matters
UEFN is a networked engine and Verse code runs partly on the server and partly on clients. Anything that mutates replicated state crosses the network. Mutating it sixty times a second per player will saturate the connection long before it saturates the CPU.
Three rules that shape this:
- Batch replicated updates. If you have ten things to change on a device, change them in one logical block. The engine coalesces tightly-grouped writes more efficiently than scattered ones.
- Don't replicate what doesn't need to be seen. A score that's only used by server-side logic doesn't need to be on a replicated property. Keep it in a Verse map keyed by
agent. - Be careful with HUD and UI updates. UI state replication is one of the heaviest paths in the engine. A debug counter that updates every tick will outweigh real gameplay logic on the network bill. Update at the slowest cadence the UX tolerates: most "real-time" displays are perfectly fine at 4–10 Hz.
Profile, don't guess
Verse and UEFN expose enough instrumentation that you don't need to guess where time is going. The in-editor profiler will surface hot devices, hot Verse functions, and net-replication spikes. A workflow that works:
- Build the system the obvious way first. Don't pre-optimize.
- Run a 16-player session in the editor or on a private session.
- Open the profiler and look for outliers: a function that's 5x the average, a device call that fires 100x per frame, a tick frame that's 3x the surrounding ones.
- Optimize the outliers, not the averages. A 200ms spike every five seconds is more noticeable to players than a uniform 8ms tick.
- Re-profile. Performance work without measurement is performance theater.
A common surprise: the slowest part of your map is rarely the part you spent the most time writing. It's almost always a quietly-added watcher loop, a forgotten Sleep(0) polling pattern, or a debug log that's still running in production. The profiler will find them in five minutes; reasoning won't find them in five hours.
Checklist for a map that scales
- No polling loops where an event subscription would work.
-
spawncount per player is bounded and small. - All hot per-tick lookups are cached, not derived.
- Common-path code uses success-shaped flow, not failure recovery.
- Replicated state updates are batched and rate-limited.
- Profiler has been run with a realistic player count, not just a solo session.
- HUD and UI update at the slowest cadence the UX tolerates.
- No debug logs or counters left running in published builds.
If those eight items are clean, your map will hold its tick rate when it matters. The rest of the work (the Verse code that makes your design feel right) gets to spend its budget on gameplay, not on fighting an architecture problem.
Performance in UEFN isn't about fast functions. It's about doing less work, doing it once, and doing it only when something actually changed. Build the architecture with that in mind from day one and you almost never end up in the late-stage scramble to claw back tick time before launch.
Related reading
- Writing your very first Verse script? Day Three: Your first Verse script (for people who have never coded) is the gentlest possible on-ramp.
- Definitions for Verse, transactional memory, creative_device, decides, and transacts live in the Mapwright glossary.
- The patterns here protect tick rate; the retention engineering guide and the economy field guide decide what to spend that tick rate on.
- Run high-load Verse against synthetic sessions in the Mapwright simulator before you ship.

