Verse Wiki — the Verse handbook for Blueprint authors
Advanced · EXTRA

Event-Driven vs Polling: Costing Out Two Architectures

"Don't put logic in Event Tick" is advice Unreal has been repeating for decades. This page writes the same requirement both ways and itemizes the bill: per-second cost, response latency, missed events, readability — plus the few cases where polling is still the right call.

1. One Requirement, Two Shapes

The requirement comes from a real question on the Epic developer forums: a patrol loop runs in the background, and pressing a stop button should end it. The beginner instinct is to write break inside the subscribed handler — a dead end, because break can only live inside the loop body, and the handler is a different stretch of code that cannot reach that loop.

That leaves two workable roads. Approach A (polling): the handler only raises a flag, and the loop checks it once per lap.

patrol_flag_device.verse
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }

patrol_flag_device := class(creative_device):

    @editable
    StopButton:button_device = button_device{}

    var ShouldStop:logic = false

    OnBegin<override>()<suspends>:void =
        StopButton.InteractedWithEvent.Subscribe(OnStop)
        Patrol()

    OnStop(Agent:agent):void =
        # The handler does exactly one thing: raise the flag
        set ShouldStop = true

    Patrol()<suspends>:void =
        loop:
            if (ShouldStop?):
                break
            Print("Patrolling...")
            # Yield every lap, or the handler never gets a chance to run
            Sleep(0.0)
        Print("Patrol over")

This is exactly how you'd do it in Blueprints: a bool variable plus a Branch in Event Tick. Two details make or break it. The question mark in if (ShouldStop?) reads this logic's value (Lesson 12's failure context); and that Sleep(0.0) in the loop is non-negotiable — as Lesson 23 taught, a loop with no yield point clamps down and never lets go, the handler never runs, and the flag never rises.

Approach B (event-driven): put "patrol" and "wait for the stop signal" into one race and let cancellation happen by itself.

patrol_race_device.verse
using { /Fortnite.com/Devices }
using { /Verse.org/Simulation }
using { /UnrealEngine.com/Temporary/Diagnostics }
# The Signal / Await of event(t) come from /Verse.org/Concurrency
using { /Verse.org/Concurrency }

patrol_race_device := class(creative_device):

    @editable
    StopButton:button_device = button_device{}

    StopEvent:event() = event(){}

    OnBegin<override>()<suspends>:void =
        StopButton.InteractedWithEvent.Subscribe(OnStop)
        race:
            block:
                loop:
                    Print("Patrolling...")
                    Sleep(1.0)
            block:
                # This arm quietly waits for the signal; the moment it lands, it wins
                StopEvent.Await()
        Print("Patrol over")

    OnStop(Agent:agent):void =
        StopEvent.Signal()

Walk the chain: player presses the button → OnStop runs → StopEvent.Signal() fires → the second arm's Await() catches it and that arm counts as finished → race declares it first → the patrol arm is cut → execution reaches Print("Patrol over"). Lesson 24's cancellation rule is at work here: the losing loop arm is not choked off at the instant of Signal — it runs to its next yield point (that Sleep) before exiting.

Approach B has no flag and no break; "patrol until the stop signal arrives" is almost plain English. This is one of the biggest structural dividends a Blueprint author collects in Verse — Blueprints have no race, so over there only Approach A exists.

2. Itemizing the Bill

Dimension Polling (Tick / loop + Sleep) Event-driven (Subscribe / Await)
Executions per second About 30 (the server's simulation-frame rhythm), whether anything happened or not Once per actual occurrence — possibly twice in a whole match
Growth with object count Linear: twenty doors means twenty exec lines spinning every frame Constant: a subscription is an attached record, not a per-frame budget item
Response latency Up to one frame late (about 1/30 s) Runs the moment the event happens
Can it miss things? Yes. It only sees "what the state is now"; press-and-release within one frame is lost No. An event records "what happened," and none of them slip through
Readability Intent is scattered across a flag, a loop and a handler "When X happens, do Y" is almost self-documenting
Cleanup cost You reset the flag; you exit the loop race cancels the loser for you; Await is one-shot and leaves no record

The most underrated row is "can it miss things." A performance gap can be absorbed by better hardware; missed events are a correctness problem — they show up as "sometimes it just doesn't work," the hardest and most expensive class of bug to reproduce. The root cause is simple: polling samples state, events record change, and state sampling is blind by construction to whatever happened between two samples.

3. When Polling Is Still Right

To be fair: polling isn't a sin, it's just overused. In the cases below, a per-frame loop remains the correct answer.

One test settles most of these decisions for you: ask whether the thing is discrete or continuous. Discrete (pressed, entered, died, picked up) means events; continuous (position, progress, a ticking time display) means a loop. The same test holds in Blueprints — Verse just makes "wait for one thing to happen" so much easier to write that the temptation to abuse Tick shrinks.

In the race approach, when does the patrol loop actually stop after StopEvent.Signal()?

Sources

Compiled from the Epic developer forums and official documentation: