5

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;
}  
megehold
  • 55
  • 1
  • 4
  • 1
    Required reading: [Avoid Async Void](https://learn.microsoft.com/en-us/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming#avoid-async-void). I don't know if this general advice applies to your case, because I am unfamiliar with game applications in general. – Theodor Zoulias Nov 18 '21 at 21:42

5 Answers5

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

Theraot
  • 31,890
  • 5
  • 57
  • 86
  • 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). – megehold Dec 11 '21 at 19:58
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.

Jeroen van Langen
  • 21,446
  • 3
  • 42
  • 57
  • 1
    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) – Theodor Zoulias Nov 18 '21 at 21:44
  • There are tons of ways to make it better, this example is all about the technique. – Jeroen van Langen Nov 18 '21 at 21:54
  • Could you share some ideas about ways to make it better? – Theodor Zoulias Nov 18 '21 at 22:26
  • I think it will lead to off-topic optimalizations. I'd rather stick on showing an easier technique not using timers/async. – Jeroen van Langen Nov 18 '21 at 22:55
  • 1
    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. – Theodor Zoulias Nov 18 '21 at 23:06
  • 1
    That's a valid point. I'll try to make a better version. I Agree with you, being critical on not making buggy code. – Jeroen van Langen Nov 18 '21 at 23:50
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

albertovilches
  • 340
  • 1
  • 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. – Theraot Nov 19 '21 at 12:55
  • `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. – albertovilches Nov 20 '21 at 17:23
  • Where are you looking? What I find is `Stopwatch` uses performance counters ([here](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Diagnostics/Stopwatch.cs#L114)), and `Task.Delay` uses a system timer. While we are at it, we can also have a look at Godot ([here](https://github.com/godotengine/godot/blob/3.x/main/main.cpp#L2114)), it uses `get_ticks_usec`, and on windows that is performance counters ([here](https://github.com/godotengine/godot/blob/3.x/platform/windows/os_windows.cpp#L2521)). – Theraot Nov 20 '21 at 18:10
  • 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 code – albertovilches Nov 21 '21 at 19:32
  • 1
    I found a good FAQ on MSDN about performance counters: [General FAQ about QPC and TSC](https://learn.microsoft.com/en-us/windows/win32/sysinfo/acquiring-high-resolution-time-stamps#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. – Theraot Nov 24 '21 at 12:53
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.

Stelios Giakoumidis
  • 2,153
  • 1
  • 7
  • 19
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:

Yunnosch
  • 26,130
  • 9
  • 42
  • 54
Andy Lee
  • 21
  • 3
  • @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. – Andy Lee Sep 25 '22 at 17:05
  • 1
    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. – Yunnosch Sep 25 '22 at 17:07