How can I delay a loop's iterations to allow time for a callback function to occur?

I’ll start with the TL;DR version: I have a loop in which a tracker device increments itself, while keeping count of incrementations; when the tracker completes I want the loop to break. However, the only way I know to detect a tracker is completed is to subscribe to its CompleteEvent. This means that the loop increments well past it’s intended bounds (within one timestamp) while the tracker device doesn’t respond until a bit later; so, instead of incrementing 5 times, it will increment many many times and can cause an infinite loop if you’re not careful. Is there a way to build in a slight delay to each iteration so that the script has time each loop iteration to await the CompleteEvent callback?

A bit more detail: the script I’m writing implements an ammo “overstock” system. If the player’s ammo–in this case, rockets–exceeds a certain amount, one rocket is taken away and “placed in” the overstock by incrementing the tracker device. If the player’s ammo goes below a threshold, the tracker is decremented and the player is granted a rocket. The tracker device is used because of it’s Save/Load functionality, which will be necessary.

The problem is: the tracker device currently has no way to get the current tracked value. You can increment or decrement, and get the max value, but not the current value. So when loading in to the level, I’m having the script “detect” the current value as above: each incrementation is kept track of in an integer variable, the tracker is reset at the end, the incrementations are subtracted from the tracker’s max value, and that gives us the current value in the tracker.

The problem is that if I use loops (if loops or the loop function) I have the stated problem above. I can get it to work by replacing the loops and having it iterate using a timer device, but that’s slow: the minimum timestep for a timer is one second (as far as I know) and if the player has a maximum overflow of, say, a hundred rockets, they have to stand there waiting for the counter for over a minute and a half before they can play, which isn’t ideal.

So, can I put delays in the loop, or does anyone have some other tips I could use?

 # Called once at load in, to initialize the player's ammo overstock count from the tracker            
    GetCurrentTrackerLevel(InPlayer:?agent) : int = 
        var OverstockEmptyCounter : int = 0
        set OverstockEmptyCounter = 0
        
        # 12 iterations is sufficient for testing rockets
        for (X := 0 .. 12):
            # When the tracker completes, "set MaxOverstockReached = true"
            OverstockTracker.CompleteEvent.Subscribe(AmmoIsFull)
            Print("Looping:")
            # Routine called when the counter (or loop) completes
            if (MaxOverstockReached = true):
                Print("Running AmmoFull Routine...")
                if (MyAgent := InPlayer?):
                    # This loop doesn't actually work right, but is demonstrative.
                    for (I := 1..OverstockEmptyCounter):
                        Print("Decrementing {OverstockEmptyCounter} times...")
                        OverstockTracker.Decrement(MyAgent)
                        if (OverstockEmptyCounter > 0): 
                            set MaxOverstockReached = false
            
            # Routine called when counting
            else if (MaxOverstockReached = false):
                # HERE IS THE PROBLEM. It increments through the loop until it reads that the tracker device has
                #   detected completion, at which point the function is called that flags "MaxOverstockReached = true".
                #   The problem is, this loop is too fast. If I set it to (X := 0 .. 100), for example, it counts up to like
                #   47 all in the same timestep, whereas the callback function from the tracker device isn't actually called until
                #   a bit later. So, can I program in some sort of delay, or asynchronicity?
                if (MyAgent := InPlayer?):
                        Print("Incrementing from {OverstockEmptyCounter}")
                        OverstockTracker.Increment(MyAgent)
                        set OverstockEmptyCounter += 1
            if (OverstockEmptyCounter >= 10):
                # Self-diagnostic tool. Ignore this.
                Print("Something's gone wrong.")
        return {MaxRockets-OverstockEmptyCounter}

NB: Apologies for the post length, but this is a good one and an important problem that a lot of people seem to struggle with. So I write this with the hope that others new to Verse might find it useful in making their games jank-less. Which benefits us all!

Apologies in advance for not reading the whole thing, I read the first part, did a skim of the rest and the code…

…I see you want to do a delay, but there is no concurrency in your code. Perhaps I can help with that? :paperclip:

Ok, so it sounds like you want to do a busy-wait or some background work until some point in future. First, you’ll need to learn about a few things:

  1. Concurrency. Specifically, the spawn: call. This allows your code to fork. Call a method with spawn: (method must have <suspends> effect), and then you can call Sleep(0.0) to Sleep one frame (i.e. wait for next tick), or Sleep(1.0) for e.g. to sleep one second. Doing this under a spawn is required because if you Sleep without it, everything will freeze :slight_smile:
  2. The <transacts> effect. Using this, you can make a “SetFlag” method that just sets some logic var to true. Using this “Setter” method with <transacts> is essential, because in your busy-wait loop you can check if that is true or false. And this update is immediate, or “atomic”.

So, in the code you’ve given… I can’t see where MaxOverstockReached is defined, but I’m guessing it’s a class variable. The problem here is that you’re calling set MaxOverstockReached = false but this essentially does nothing, it will only remain false in the current scope/block (i.e. the body of the condition it’s inside: if (OverstockEmptyCounter > 0): . If you want to set that variable and make it “immediately visible everywhere”, then you need the <transacts> effect on the method that sets it. HOWEVER you will need to make a separate method for that (called a “Setter”), because if you put <transacts> on your main method then you will almost always get a no_rollback error on some other call. So a separate “Setter” with <transacts> effect is usually required.

I hope I’ve helped, again sorry that I didn’t closely read it all - but this is the general idea of how you do things “later”, or “in the background”. Here is an example incorporating both of the above; two busy-waits for different purposes and a “flag setter” method for letting the spawned task “echo back to the main thread”:

game_loop_example := class(creative_device) {
    var AllTheThingsLoaded : logic = false
    SetAllTheThingsLoaded()<transacts> : void = { set AllTheThingsLoaded = true } # this is the "setter"; I keep it up near the variable since it makes more sense to me

    OnBegin<override>()<suspends> : void = {
        # Start some long running task
        Print("Loading all the things...")
        spawn:
            LoadAllTheThings()

        # This is a "busy-wait" or "spinlock". It will skip each tick (a server/logic frame) until AllTheThingsLoaded is true
        loop:
            if (AllTheThingsLoaded = true):
                break # abort the loop
            else:
                Sleep(0.0) # sleep for one frame
        
        Print("...all the things loaded! Starting game loop...")

        # Now that AllTheThingsLoaded = true we can continue with GameLoop
        spawn:
            GameLoop()
    }

    LoadAllTheThings()<suspends> : void = {
        # Can do something here that takes a long time to do. It runs in the background, doesn't freeze main game, since we called it with a spawn task
        # This example just sleeps for 10 seconds
        Sleep(10.0)
        # Set AllTheThingsLoaded to true. Very important to call the <transacts> method here, so the change is visible everywhere instead of just this code block
        SetAllTheThingsLoaded()
    }

    GameLoop()<suspends> : void = {
        # This is another "busy wait". But in this example the busy-wait runs forever, representing code you want to perform every game tick
        loop {
            # Do something here every frame. We can for example check if another variale is some value then call a function. We could even iterate over an array, then clear the array - simulating an "internal event bus" system
            Sleep(0.0)
        }
    } 
}

Do note that this whole busy-wait thing is “technically bad”, don’t rely on it too much - simply because it wastes time when there’s nothing to do. A spawned thread doing nothing but looping forever still costs resources. For things that happen regularly and sporadically, using custom events and just a butt-load of methods that call each other, one after the other, is usually better for performance (for a more “Functional” or Component/decoupled game design). But a core game loop is a really simple, universal example of a busy-wait or background-work, and why they’re sometimes useful or necessary.

EDIT: Forgot to back-quote code things
EDIT2: Better code (IMO) and comments
EDIT3: Typo’s… I really should pay more attention to previews :cowboy_hat_face:

Hey, no worries, thats why I left the summary at the top. I was looking for advice on how to proceed and you gave me that in spades, so thanks a lot!

I’ll be out of town for most of the rest of the week, but this weekend I’ll buckle down, test some things out, and let you know if this is solved (and how). Thanks again!

No problem at all, I felt obligated to write a lot of detail because this is a good question and an important issue for good quality software.

Cheers and have a fun/productive week, I’ll be around!

Alright, I finally got around to working on this (decided I needed to restart my map from scratch first). To make a long story short, you were right, I needed to break the tracker counting loop out into it’s own thread. Doing so and putting a tiny pause in each iteration was just what I needed to count quickly while not overcounting past the maximum. Thanks!

Not expecting you to read through this of course, but in case anyone comes across this and wants to see where I ended up, I’ll drop the code below. I’ll rewrite it into a snippet sometime in the future once I fix a couple conceptual issues, but if you want to see how I did it, check out the OnStashTimerComplete() and CalculateAmmoInOverstock() functions.

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

<## 
    
    ROCKET OVERSTOCK DEVICE 1.0 (4/26/2022) by bird stink (twt: @BirdStink)
    This device handles the ammo overstock function for Rocket Blast. If the player is carrying too many rockets, the excess are sent to an "overstock"
    which is tracked by a tracker device. If the player is carrying too few rockets, the script checks if there are any rockets in the overstock and
    grants them to the player.
    NOTE: Persistence can't be proven right now (4/26/23) because the Save device apparently doesn't work.

        v1.0 4/26/2023: Utilizes concurrency. Much faster load time. Restock/Replenish still 1/sec, but that's fine for now.
        Orig: 4/10/2023
##>


single_ammo_overstock_device := class(creative_device):
    @editable
    ThresholdPlusOneConditionalButton:conditional_button_device = conditional_button_device{}
    @editable
    ThresholdConditionalButton:conditional_button_device = conditional_button_device{}
    @editable
    OverstockTracker:tracker_device = tracker_device{}
    @editable
    ItemGranter:item_granter_device = item_granter_device{}
    @editable
    ItemDropper:item_granter_device = item_granter_device{}
    @editable
    ItemRemover:item_remover_device = item_remover_device{}
    @editable
    Timer:timer_device = timer_device{}
    @editable
    StashTimer:timer_device = timer_device{}
    var OverstockLoaded : logic = false
    SetOverstockLoaded()<transacts> : void = {set OverstockLoaded = true}
    var MaxRockets : int = 0
    var MaxOverstockReached : logic = false
    var CurrentStash : int = 0
    var WasMaxAmmoHit : logic = false
    var InitialCalcDone : logic = false


    # Runs when the device is started in a running game
    OnBegin<override>()<suspends>:void=
        Timer.SuccessEvent.Subscribe(OnTimerComplete)
        set MaxRockets = OverstockTracker.GetTarget()
        OverstockTracker.CompleteEvent.Subscribe(HitMaxAmmo)
        StashTimer.SuccessEvent.Subscribe(OnStashTimerComplete)
        ThresholdConditionalButton.NotEnoughItemsEvent.Subscribe(TryToWithdrawRockets)
        ThresholdPlusOneConditionalButton.ActivatedEvent.Subscribe(TryToDepositRockets)


    # Once per second callback after load is done. 
    OnTimerComplete(InPlayer:?agent) : void =
        if (InitialCalcDone?):
            # Print("Stash Routine")
            set MaxRockets = OverstockTracker.GetTarget()
            if ( MyAgent := InPlayer?):
                StashTimer.Disable(MyAgent)
                ThresholdConditionalButton.Activate(MyAgent)
                ThresholdPlusOneConditionalButton.Activate(MyAgent)


    # One second after arrival, starts the Overstock load sequence.
    OnStashTimerComplete(InPlayer:?agent) : void =
        spawn:
            CalculateAmmoInOverstock(InPlayer)


    # Automatically determines the current value of the ammo tracker. 
    # Uses concurrency to operate quickly while allowing for tracker complete signal.        
    CalculateAmmoInOverstock(InPlayer: ?agent)<suspends> : void =
        loop:
            if (OverstockLoaded = true):
                break
            Sleep(0.01)
            OverstockTracker.CompleteEvent.Subscribe(HitMaxAmmo)
            if (WasMaxAmmoHit = false):
                # Print("Counting space...")
                set CurrentStash += 1
                if (MyAgent := InPlayer?):
                    OverstockTracker.Increment(MyAgent)
            if (WasMaxAmmoHit = true):
                if (InitialCalcDone = false):
                    set InitialCalcDone = true
                    # Print("Counting complete. Initiating overstock system.")
                    # Print("Enabling stock timer...")
                    if (MyAgent := InPlayer?):
                        for (I := 1 .. MaxRockets):
                            OverstockTracker.Decrement(MyAgent)
                        for (I := 1 .. CurrentStash):
                            OverstockTracker.Increment(MyAgent)
                        # Print("Disabling StashTimer...")
                    SetOverstockLoaded()
                    # Print("Overstock Loaded!")
                

    # The tracker completion callback calls this to set the Max Ammo flag to true.
    # Only used during initialization, since it can be calculated directly afterwards.
    HitMaxAmmo(InPlayer: agent) : void =
        if (WasMaxAmmoHit = false):
            set WasMaxAmmoHit = true
            # Print("Max ammo hit, inverting...")
            set CurrentStash = {MaxRockets - CurrentStash}

            
    # If threshold is exceeded, this is called once per second by the timer.
    # Adds one rocket to the overstock until held ammo is 10.
    # If overstock is full, drops rocket (or cash) on ground as determined by Item Dropper above.
    TryToDepositRockets(InPlayer: agent) : void = 
        # Print("Depositing Rockets...")
        if (CurrentStash = MaxRockets):
            # Print("Can't deposit, maximum reached. Dropping.")
            ItemRemover.Remove(InPlayer)
            ItemDropper.GrantItem(InPlayer)
        else:
            # Print("Depositing one")
            ItemRemover.Remove(InPlayer)
            OverstockTracker.Increment(InPlayer)
            set CurrentStash += 1


    # If threshold not met, called once per second by timer.
    # Removes one rocket from overstock and gives to player. If overstock is empty, does nothing.
    TryToWithdrawRockets(InPlayer: agent) : void = 
        # Print("Attempting to withdraw rockets...")
        if (CurrentStash > 0):
            # Print("Withdrawing one")
            OverstockTracker.Decrement(InPlayer)
            ItemGranter.GrantItem(InPlayer)
            set CurrentStash -= 1
        else:
            # Print("Stash is empty. Withdrawl failed.")```
1 Like