Using C# async delays in Godot
Asked Answered
G

6

6

I am currently experimenting with Godot C# making a basic shooter and for the gun's fire rate I have been experimenting with different delay systems. Node Timers work although I'm trying to make the script generic, and the Timer calls seem to only call functions in the parent script.

I'm now looking at C#'s Task.Delay method and it also seems to work, with it being an async action it does not look to be affected by the frame rate or slow down the game.

My question is, is there any known issue for using Task.Delay in game applications: like is it unreliable or can it crash if too many instances of the method are called?

Here's the code below although I don't think it’s important:

 private void shoot() {
  //if "canShoot" spawn bullet
  ShootCooledDown();
}

private async void ShootCooledDown() {
  TimeSpan span = TimeSpan.FromSeconds((double)(new decimal(shotDelay)));
  canShoot = false;
  await Task.Delay(span);
  canShoot = true;
}  
Gemmation answered 18/11, 2021 at 20:44 Comment(1)
Required reading: Avoid Async Void. I don't know if this general advice applies to your case, because I am unfamiliar with game applications in general.Compote
C
3

My question is, is there any known issue for using Task.Delay in game applications: like is it unreliable or can it crash if too many instances of the method are called?

Not per se. There is nothing in particular wrong with Task.Delay in games, nor too many instances of it.

However, what you are doing after Task.Delay can be a problem. If you execute await Task.Delay(span);, the code that comes after might run in a different thread, and thus it could cause a race condition. This is because of await, not because of Task.Delay.

For example, if after await Task.Delay(span); you will be adding a Node to the scene tree (e.g. a bullet), that will interfere with any other thread using the scene tree. And Godot will be using the scene tree every frame. A quick look at Thread-safe APIs will tell you that the scene tree is not thread-safe. By the way, the same happen with virtually any widget API out there.

The solution is use call_deferred (CallDeferred in C#) to interact with the scene tree. And, yes, that could offset the moment it happens to the next frame.


I'll give you a non threading alternative to do that.

There are method get_ticks_msec and get_ticks_usec (GetTicksMsec and GetTicksUsec in C#) on the Time class (previously OS class), that give you monotone time which you can use for time comparison.

So, if you make a queue with the times it should shoot (computed by taking the current time plus whatever interval you need). Then in your process or physics process callback, you can check the queue. Dequeue all the times that are overdue, and create those bullets.

If you don't want to solve this with Godot APIs, then start a Stopwatch at the start of the game, and use its elapsed time.


But perhaps that is not the mechanic you want anyway. If you want a good old cool-down, you can start the Stopwatch when you need the cool-down, and then compare the elapsed time with the cool-down duration you want to know if it is over.

Contemporize answered 19/11, 2021 at 0:32 Comment(3)
Thanks for letting me know about GetTicksMsec: I wasn't to keen having so many async calls for shooting (hence why I made the question).Gemmation
in Godot 4 the GetTicksMsec() and GetTicksUsec() functions have been moved to the Godot.Time api in C#. Not sure about GDScript.Robbery
@Robbery Last time I checked it was available in both places for GDScript to avoid breaking compatibility. I have amended the answer anyway.Contemporize
B
2

I don't have any experience with Godot.. but my idea would be....

instead of using a timer, you could store the last shoottime in a variable/field. If you're trying to shoot within the lastTimeShot+coolDown, just ignore the shoot command.

For example:

private DateTime _lastShot = DateTime.MinValue;

private void shoot() 
{
    TimeSpan span = TimeSpan.FromSeconds((double)(new decimal(shotDelay)));
    
    // if the time when the last shot has fire with the cooldown time
    // is greater than the current time. You are still in the cooldown time.
    if(_lastShot.Add(span) > DateTime.UtcNow)
        return; // within cooldown, do nothing
        
    //if "canShoot" spawn bullet
    ShootCooledDown();
    _lastShot = DateTime.UtcNow;
}

Due to a valid comment of Theodor, about changing the system time would lead bug-prone gameplay.

I wrote a second version.

private Stopwatch _shootingCooldownStopwatch = default;
    
private void shoot()
{
    var shotDelayMs = shotDelay * 1000;

    // if the _shootingCooldownStopwatch is ever started
    // and the ElapsedMilliseconds are in the showDelay
    // we're not allowed to fire again. So exit the method.
    if (_shootingCooldownStopwatch?.ElapsedMilliseconds < shotDelayMs)
        return;

    _shootingCooldownStopwatch = Stopwatch.StartNew();

    //if "canShoot" spawn bullet
    ShootCooledDown();
}

I think this would be a better solution.

Bluefield answered 18/11, 2021 at 21:10 Comment(6)
Instead of the DateTime.UtcNow you could consider using the Environment.TickCount, which AFAIK is not subject to system-wise clock adjustments. Or use a Stopwatch. (I didn't downvote btw)Compote
There are tons of ways to make it better, this example is all about the technique.Bluefield
Could you share some ideas about ways to make it better?Compote
I think it will lead to off-topic optimalizations. I'd rather stick on showing an easier technique not using timers/async.Bluefield
Jeroen replacing the DateTime.UtcNow with a Stopwatch is not an optimization technique. It's a bug fix. If you rely on the clock for measuring intervals, and somehow the clock is adjusted while the app is running, suddenly the player's weapon stops firing and you have a bug report to deal with.Compote
That's a valid point. I'll try to make a better version. I Agree with you, being critical on not making buggy code.Bluefield
A
1

When you develop games in Godot or any other game engine, you shouldn't use any timer based in the computer clock, like the Stopwatch or Task.delay. Instead, you have to manage yourself the time elapsed using the delta time from the previous frame, which is received in the _Process(float delta) or _PhysicsProcess(float delta) methods. The reason are:

  • The time will be more accurate in case of frame-rate drop.
  • If you pause the game, timer will pause too.

That's the main reason Godot offers you a Timer component that you have to attach to the current scene in order to work with it. If you don't want to add anything to the scene, which completely reasonable, you have to get the delta, storing the elapsed time in a variable and check if this variable reach some limit.

In my games, I use my own timers with this very simple class:

    public class Timer {
        public float Elapsed { get; private set; } = 0;
        public bool Stopped { get; private set; } = true;
        public float Alarm { get; private set; } = float.MaxValue;

        public Timer Start() {
            Stopped = false;
            return this;
        }

        public Timer Stop() {
            Stopped = true;
            return this;
        }

        public Timer Reset() {
            Elapsed = 0;
            return this;
        }

        public Timer ClearAlarm() {
            Alarm = float.MaxValue;
            return this;
        }

        public Timer SetAlarm(float finish) {
            Alarm = finish;
            return this;
        }

        public bool IsAlarm() => Elapsed > Alarm;

        public Timer Update(float delta) {
            if (!Stopped) {
                Elapsed += delta;
            }
            return this;
        }

    }
```

You have to Update the timer in every frame

Arciniega answered 19/11, 2021 at 12:10 Comment(5)
When you say "computer clock" do you mean system time? Stopwatch and Task.Delay are not based on system time. Also, there are legitimate uses for Task.Delay. For example, if you play an AudioStreamPlayer on the finished signal of another AudioStreamPlayer, there can be a whole frame from the end of one to the start of the other, and that is an audible gap. With Task.Delay (and some help from AudioServer) you can get precise scheduling, which I wish was built-in Godot. Also Stopwatch and Task.Delay use less CPU than your solution. Which, btw works the same was as Godot timers.Contemporize
Stopwatch and Task.Delay use the system time to get the elapsed time, you can check the source code and see how it uses the method public static extern long GetTimestamp(); and any other related system clock functions.Arciniega
Where are you looking? What I find is Stopwatch uses performance counters (here), and Task.Delay uses a system timer. While we are at it, we can also have a look at Godot (here), it uses get_ticks_usec, and on windows that is performance counters (here).Contemporize
I think we are talking about the same. I don’t know the performance counter concept… to me, get the ticks to know the elapsed time is the same as get the system time. If both things are different, then you are right. I’m using the Jetbrains Rider decompiler to see the Stowatch source codeArciniega
I found a good FAQ on MSDN about performance counters: General FAQ about QPC and TSC. What I call system time is what you see in the clock, which the user could change. Performance counters is not that.Contemporize
H
0

I am no expert in Godot but I can tell that Task.Delay() is considered better than alternatives like Thread.Sleep() for example because being asynchronous i releases the thread to the thread pool and when the time has passed it continues execution, in contrast to the latter option that blocks the thread instead.

The problem I can see is that each web server can accept a max limit of concurrent requests, by using Task.Delay() in your code you can start accumulating requests "just waiting" due to the delay. So if your app starts receiving a big amount of requests coupled with a long Delay time that might be an issue with requests queued up (delay) or even denied.

If the delay is a number of seconds (significant time) then I would probably think about storing user in a cache (you can also store in a dictionary Dictionary<string, bool> where string is the userId but this solution will not scale out, that is why I suggest a distributed cache), and check (TryGetValue()) your cache if user is allowed to shoot. If delay is a couple of microseconds (affordable time) still not an ideal solution but it will probably be a problem.

Heavyladen answered 18/11, 2021 at 21:10 Comment(0)
C
0

In contrast to the answer by @Theraot and its approach via await Task.Delay(span) and according to my understanding, asynchronous does NOT equal to multi-threading. Using await Task.Delay(span) won't cause your code executing in another thread. So you don't really need to use CallDeferred in this case.

Reference:

Cathicathie answered 25/9, 2022 at 16:27 Comment(2)
@Yunnosch Thank you for the edit. Yes I think this information can be classified as "truly important that it should be incorporated into an answer". And it looks like a proper answer now.Cathicathie
Good to hear that you are OK with my edit. It was little extensive after all. And yes, I agree that it can now be seen as an answer.Fishhook
O
0

With C# in Godot there is an official way to create a one-shot delay within async methods that takes into account the scene tree and is processed after all of the nodes in the current frame, taken from the Official Documentation.

public async Task SomeFunction()
{
    GD.Print("start");
    await ToSignal(GetTree().CreateTimer(1.0f), SceneTreeTimer.SignalName.Timeout);
    GD.Print("end");
}
Operative answered 18/5 at 14:39 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.