Search Unity

  1. Welcome to the Unity Forums! Please take the time to read our Code of Conduct to familiarize yourself with the forum rules and how to post constructively.
  2. Dismiss Notice

Question Logarithmic scaling difficulty based on game time

Discussion in 'Scripting' started by BelowStudios, Jun 12, 2023.

  1. BelowStudios

    BelowStudios

    Joined:
    Dec 21, 2021
    Posts:
    1
    Hey all, I am a bit new to coding, and this one has stumped me a bit. I have a game that spawns obstacles, and want to start at spawning them every 5 seconds, and as the game goes on slowly increase the spawn rate so it gets closer and closer to 2 second spawn time, but never quite hits it, based off the time.deltaTime function. I want to then be able to bump this number back closer to the 5 second mark by a percentage amount if the player gets certain power ups.

    Would I need to look at logarithmic equations for this? Any help would be appreciated!
     
  2. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    3,043
    Edit: If you read this before the edit, it's early in the morning, so I mixed up words rate and frequency.
    Edit2: I've figured what you want in the meantime, skip to the next post.

    No, this is a linear problem. Try to rephrase it as a 'frequency of spawn' instead. Frequency of spawn in your case starts at 12 per minute, then progressively rises to 30. You can shape this rise however you'd like, but it can also be perfectly linear. A power up would simply diminish this accumulated penalty by subtracting or multiplying.

    The following is pseudo-code. Here the values are expressed in Hz (1/second; although you can define design values in relation to minutes):

    Initial conditions:
    Code (csharp):
    1. Initial_freq = 0.2 (frequency of 0.2Hz or a rate of 'once every 5 seconds')
    2. Maximum_freq = 0.5 (frequency of 0.5Hz or a rate of 'once every 2 seconds')
    3. Freq_change = 0.05 (how much the frequency changes per second)
    4. Current_freq = Initial_freq
    5. Freq_penalty = 0
    This change would happen in Update:
    Code (csharp):
    1. Freq_penalty = Min(Maximum_freq - Initial_freq, Freq_penalty + Freq_change * deltaTime)
    2. Current_freq = Initial_freq + Freq_penalty
    Freq_penalty is guaranteed to never grow beyond (Maximum_freq - Initial_freq)
    Current_freq is guaranteed to never get below Initial_freq

    Collecting a power-up would then do
    Code (csharp):
    1. Freq_penalty = Max(0, Freq_penalty - Power_up_aid) (aid should be > 0)
    or
    Code (csharp):
    1. Freq_penalty *= Power_up_multiplier (multiplier should be > 0 and < 1)
    This is how to check whether to spawn something (in Update again)
    Code (csharp):
    1. Time_lapsed += deltaTime (Time_lapsed would start from 0 in Awake)
    2. Spawn_rate = 1f / Current_freq;
    3. if(Time_lapsed >= Spawn_rate) {
    4.   *trigger*
    5.   Time_lapsed -= Spawn_rate
    6. }
    In place of *trigger* you either call a method to spawn something, or raise a Boolean "flag" which can be verified elsewhere. For example (actual code)
    Code (csharp):
    1. public bool SpawnReady { get; set; } // this is a class property
    then in Update, instead of *trigger* you do
    Code (csharp):
    1. if(_lapsed >= spawnRate) {
    2.   SpawnReady = true;
    3.   _lapsed -= spawnRate;
    4. }
    If some other code checks this flag in order to spawn something, make sure to set it back to
    false
    . Or you can make it like this
    Code (csharp):
    1. bool _spawnReady;
    2.  
    3. public bool IsReadyToSpawn() {
    4.   return _spawnReady;
    5. }
    6.  
    7. public void NotifySpawn() {
    8.   if(!_spawnReady) Debug.LogWarning("Spawning too soon!");
    9.   _spawnReady = false;
    10. }
    If you're not versed with how to do this and don't know C# or Unity enough, just ask what part of this is confusing to you and I'll try to explain.
     
    Last edited: Jun 12, 2023
  3. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    3,043
    Ah I've just comprehended this part
    Ok, then this is asymptotic in nature. And yes you can use exponential function to achieve this.
    Give me a moment to work this out.

    Edit:
    You can shape an exponential decay with the following equation
    f(t) = c + ae^kt

    Then instead of linearly accumulating penalty, you use the penalty time as x argument to this formula. a, c, and k are shaping parameters, where k should be negative for decay, a is the scaling factor (0.3 in your example), and c is the absolute offset (0.2 in your example).

    With these parameters you'd get an exponential decline from 0.5 to 0.2 and the rate of change is dictated by k (the greater its absolute value, the quicker the change).

    You can roughly estimate the "boundary" of this decay*, at 2^e/-k it will get really close to the lower bound, and at e/-k the main dynamic of the curve is already mostly spent (more than 90%). So by observing these values, you can get a rough idea of how long the decay would last.

    * Edit2: Just to clarify this, there is no real boundary, it will go on forever. (Also fixed a mistake, 2e/-k should've been 2^e/-k)

    You can try copy/pasting this on desmos.com
    y=c+ae^{kx} \ \left\{x\ge0\right\}

    Edit3: Here's a better answer on how to probe the curve precisely
    Let's say a question is "what should be t so that the function covers 90% of value space?"
    The answer is ln(1 - 0.9) / k or
    MathF.Log(1f - t) / k


    You can now probe the curve with 50% and 99%, and with k of -0.01 this will give you 69.314 and 460.517.

    In other words the penalty will grow to 50% after 1 minute and 9 seconds and 99% after 7 minutes and 40 seconds.
     
    Last edited: Jun 12, 2023
  4. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    3,043
    In code the sampling would look like this
    Code (csharp):
    1. MathF.Exp(k * t);
    Where
    t
    is current time in seconds. You can apply other shaping parameters on the spot.

    Here's an example in code (you'll find improved version in post #7)
    Code (csharp):
    1. using System;
    2. using UnityEngine;
    3.  
    4. public class SpawnClock : MonoBehaviour {
    5.  
    6.   private const int SECONDS = 60;
    7.  
    8.   static SpawnClock _instance;
    9.   static public SpawnClock Instance => _instance;
    10.  
    11.   [SerializeField] [Min(0)] int _startRPM = 12; // starting rate per minute
    12.   [SerializeField] [Min(0)] int _maxRPM = 30; // maximum rate per minute
    13.   [SerializeField] [Min(1E-8f)] float _expDecay = 0.05f; // reaches 99.9% in about 138 seconds
    14.  
    15.   float _initialFreq;
    16.   float _maxFreq;
    17.   float _decayTime;
    18.   float _lapsed;
    19.  
    20.   public bool IsReady { get; private set; }
    21.  
    22. #if UNITY_EDITOR
    23.   void OnValidate() {
    24.     var d50 = _expDecay == 0f? 0 : (int)(MathF.Log(.5f) / _expDecay);
    25.     var d999 = _expDecay == 0f? 0 : (int)(MathF.Log(1E-3f) / _expDecay);
    26.     Debug.Log($"Decay of {_expDecay:F2} will last {toMinSecString(d50)} to reach 50% and {toMinSecString(d999)} to reach 99.9%");
    27.   }
    28. #endif
    29.  
    30.   void Awake() {
    31.     _instance = this;
    32.     _decayTime = 0f;
    33.     _lapsed = 0f;
    34.     _initialFreq = rpmToFreq(_startRPM);
    35.     _maxFreq = MathF.Max(rpmToFreq(_maxRPM), _initialFreq);
    36.   }
    37.  
    38.   void Update() {
    39.     var dt = Time.deltaTime;
    40.  
    41.     // update timers
    42.     _decayTime += dt;
    43.     _lapsed += dt;
    44.  
    45.     // max frequency validation
    46.     if(_maxFreq == 0f) {
    47.       IsReady = false;
    48.       return;
    49.     }
    50.  
    51.     // update frequency
    52.     var range = _maxFreq - _initialFreq;
    53.     var freqPenalty = range * (1f - MathF.Exp(-_expDecay * _decayTime));
    54.     var curFreq = _initialFreq + freqPenalty;
    55.  
    56.     // frequency validation
    57.     if(curFreq == 0f) {
    58.       IsReady = false;
    59.       return;
    60.     }
    61.  
    62.     // update spawn status
    63.     var spawnInterval = 1f / curFreq;
    64.  
    65.     if(_lapsed >= spawnInterval) {
    66.       IsReady = true;
    67.       _lapsed -= spawnInterval;
    68.     }
    69.   }
    70.  
    71.   public void NotifySpawning() {
    72.     if(!IsReady) Debug.LogWarning("Spawning too soon! Check for IsReady status.");
    73.     IsReady = false;
    74.   }
    75.  
    76.   public void ClearPenalties() {
    77.     _decayTime = 0f;
    78.   }
    79.  
    80.   static float rpmToFreq(int rpm)
    81.     => (float)rpm / SECONDS;
    82.  
    83.   static string toMinSecString(int s) {
    84.     var mins = s / SECONDS;
    85.     var secs = s % SECONDS;
    86.     return $"{mins:#0}:{secs:00}";
    87.   }
    88.  
    89. }
    You can now access this from anywhere
    Code (csharp):
    1. var sc = SpawnClock.Instance;
    Some Spawner might do
    Code (csharp):
    1. void Update() {
    2.   if(SpawnClock.Instance.IsReady) {
    3.     SpawnMonster();
    4.     SpawnClock.Instance.NotifySpawning();
    5.   }
    6. }
    And likewise when you collect a power up you can just
    Code (csharp):
    1. SpawnClock.Instance.ClearPenalties();
    Didn't test this, hopefully the math is correct.

    Edit: Changed OnValidate code to show the precise calculations of 50% and 99%
    Edit2: Changed the latter to 99.9%
     
    Last edited: Jun 12, 2023
  5. palex-nx

    palex-nx

    Joined:
    Jul 23, 2018
    Posts:
    1,745
    No, you don't really need equations to implement this in unity. There's AnimationCurve for you to use. You only need

    [SerializeField] AnimationCurve complexity;

    Now you can make a visual curve to represent the desired complexity of your game via unity inspector. In the code you make

    var complexityFactor = complexity.Evaluate(game time / complexity increase duration);

    to get the complexity factor. After that multiply it by 5 - 2 and add 2. That's it.
     
  6. Kurt-Dekker

    Kurt-Dekker

    Joined:
    Mar 16, 2013
    Posts:
    36,563
    I'm not sure that will satisfy OP's request (but I really love AnimationCurves!!!)... while OP didn't say so, it feels to me like it might be an endless runner kinda game, in which case there's no way to set the right side of the AnimationCurve domain.

    I like this approach for endlessly asymptotic things:

    Code (csharp):
    1. private float age;  // time in seconds you have been playing the game.
    Code (csharp):
    1. // in Update():
    2. age += Time.deltaTime;
    3.  
    4. float curve = K / (K + age);
    5.  
    6. float spawnInterval = 2 + 3 * curve;
    That's it.

    Choose
    K
    to get the kind of falloff you want:

    - smaller K falls off sharply

    - large K falls of gradually

    For instance:

    - a
    K
    of 1 will result in falling to (2 + 1.5) at the 1-second mark
    - a
    K
    of 10 will result in falling to (2 + 1.5) at the 10-second mark

    Then obviously your spawn check is simply:

    Code (csharp):
    1. private float spawnYet;
    and

    Code (csharp):
    1. // also in Update():
    2. spawnYet += Time.deltaTime;
    3. if (spawnYet > spawnInterval)
    4. {
    5.   spawnYet = 0;
    6.   // TODO: spawn!
    7. }
    Keep in mind though it's often "nice" to dragon-tail the difficulty-related stuff: introduce another quantity that goes up and down like the spikes on a dragon tail. For this you can add a Mathf.PingPong() term on top of the interval.
     
    Last edited: Jun 12, 2023
    orionsyndrome likes this.
  7. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    3,043
    Btw, if you want to tie the duration of the decay to seconds, you can also do this.

    It's the same code as before, just remove
    _expDecay
    and add
    _toReach
    and
    _duration
    instead.
    This will arrive at the same conclusion but from slightly friendlier parameters in the inspector.
    Code (csharp):
    1. using System;
    2. using UnityEngine;
    3.  
    4. public class SpawnClock : MonoBehaviour {
    5.  
    6.   private const int SECONDS = 60;
    7.  
    8.   static SpawnClock _instance;
    9.   static public SpawnClock Instance => _instance;
    10.  
    11.   [Header("Spawn Rates")]
    12.   [SerializeField] [Min(0)] int _startRPM = 12; // starting rate per minute
    13.   [SerializeField] [Min(0)] int _maxRPM = 30; // maximum rate per minute
    14.   [Header("Frequency Modulation")]
    15.   [SerializeField] [Range(0f, 1f)] float _toReach = .999f;
    16.   [SerializeField] [Range(1, 1800)] int _duration = 180; // in seconds
    17.  
    18.   double _expDecay;
    19.   double _decayTime;
    20.   float _lapsed;
    21.   float _initialFreq;
    22.   float _maxFreq;
    23.  
    24.   public bool IsReady { get; private set; }
    25.  
    26. #if UNITY_EDITOR
    27.   void OnValidate() {
    28.     var expd = (float)computeExpDecay();
    29.     var d50 = expd == 0f? 0 : (int)(MathF.Log(.5f) / expd);
    30.     Debug.Log($"Decay of {expd:F4} will take {toMinSecString(d50)} to reach 50% and {toMinSecString(_duration)} to reach {_toReach*100f:F2}%");
    31.   }
    32. #endif
    33.  
    34.   double computeExpDecay()
    35.     => _toReach >= 1f? 0d : Math.Log(1d - _toReach) / (double)_duration;
    36.  
    37.   void Awake() {
    38.     _instance = this;
    39.     _decayTime = 0d;
    40.     _lapsed = 0f;
    41.     _initialFreq = rpmToFreq(_startRPM);
    42.     _maxFreq = MathF.Max(rpmToFreq(_maxRPM), _initialFreq);
    43.     _expDecay = computeExpDecay();
    44.   }
    45.  
    46.   void Update() {
    47.     var dt = Time.deltaTime;
    48.  
    49.     // update timers
    50.     _decayTime += dt;
    51.     _lapsed += dt;
    52.  
    53.     // max frequency validation
    54.     if(_maxFreq == 0f) {
    55.       IsReady = false;
    56.       return;
    57.     }
    58.  
    59.     // update frequency
    60.     var range = _maxFreq - _initialFreq;
    61.     var freqPenalty = range * (1f - (float)Math.Exp(_expDecay * _decayTime));
    62.     var curFreq = _initialFreq + freqPenalty;
    63.  
    64.     // frequency validation
    65.     if(curFreq == 0f) {
    66.       IsReady = false;
    67.       return;
    68.     }
    69.  
    70.     // update spawn status
    71.     var spawnInterval = 1f / curFreq;
    72.  
    73.     if(_lapsed >= spawnInterval) {
    74.       IsReady = true;
    75.       _lapsed -= spawnInterval;
    76.     }
    77.   }
    78.  
    79.   public void NotifySpawning() {
    80.     if(!IsReady) Debug.LogWarning("Spawning too soon! Check for IsReady status.");
    81.     IsReady = false;
    82.   }
    83.  
    84.   public void ClearPenalties() {
    85.     _decayTime = 0d;
    86.   }
    87.  
    88.   static float rpmToFreq(int rpm)
    89.     => (float)rpm / SECONDS;
    90.  
    91.   static string toMinSecString(int s) {
    92.     var mins = s / SECONDS;
    93.     var secs = s % SECONDS;
    94.     return $"{mins:#0}:{secs:00}";
    95.   }
    96.  
    97. }

    You can also play with the curve on Desmos << click
    d = duration, s = a value to reach at duration d; red line shows 50%

    Edit: Changed code to support doubles for better precision and extra long durations.

    Edit2: Btw I would recommend a
    _toReach
    of 0.75.
    Here you can see it (as a yellow curve) compared with the normal linear rise (green line)

    upload_2023-6-12_18-35-59.png

    It's not that much of a difference in the first half, but gives you this nice gradual fall off once you move onto the second half. [Desmos]

    There's also this unreasonably interactive version [Desmos]

    Edit: I've tested the code, seems to be working properly.
     
    Last edited: Jun 13, 2023
    Kurt-Dekker likes this.
  8. orionsyndrome

    orionsyndrome

    Joined:
    May 4, 2014
    Posts:
    3,043
    Here's another example in Desmos, where instead of having to adjust
    _toReach
    manually, you instead have a
    _steepness
    parameter. Perhaps it's more intuitive.
    Code (csharp):
    1. [SerializeField] [Range(0f, 1f)] float _steepness = 0.2f;
    _toReach
    is still used beneath the hood, and it can be computed as
    Code (csharp):
    1. _toReach = 1f - MathF.Pow(10f, -10f * _steepness);
    It all comes down to your preferences.