Skip to main content
GameDev.net gamedev.net
🎮 Unity

making a fighting game but need help understanding how to make it deterministic

Started by Mr Quivy Sep 26 at 8:11 PM 3 replies 250+ views
Original Post
Mr Quivy
Mr Quivy

I don't under stand how fighting games are able to be made and be deterministic at the same time. For example how am I supposed to keep track of the players position without using float!? Let alone accelerate them at a fixed reasonable speed? I don't know how I would do this and I really need help. I used gemini to help me write a vector3 fixed point class but I'm still not confident that this is good enough to use it.

using System;
using Godot;

namespace ProjectKazen.Framework.Util;
public class FixedPoint
{
protected const int Shift = 16;
protected const long One = 1L << Shift;
public static float LongToFloat(long Value) => (float)Value / One;
public static long FloatToLong(float Value) => (long)MathF.Floor(Value * One);

public class Vector3 : FixedPoint
{
    public readonly long RawX;
    public readonly long RawY;
    public readonly long RawZ;
    public Godot.Vector3 ToFloatData => new(LongToFloat(RawX),LongToFloat(RawY),LongToFloat(RawZ));
    //Convert the raw into deterministic values by shifting left because ints have a less bytes than longs
    public int X => (int)(RawX >> Shift);
    public int Y => (int)(RawY >> Shift);
    public int Z => (int)(RawZ >> Shift);
    public Vector3I FixedData => new(X, Y, Z);
    public Vector3(int X, int Y, int Z)
    {
        //Shift XYZ by 16 to the left and convert to long because the number the ints can represent need to be bigger
        RawX = (long)X << Shift;
        RawY = (long)Y << Shift;
        RawZ = (long)Z << Shift;
    }
    public Vector3(long X, long Y, long Z)
    {
        RawX = X;
        RawY = Y;
        RawZ = Z;
    }
    public static implicit operator Vector3I(Vector3 Self) => Self.FixedData;
    // Math operators cause the IDE can't do it for us 😭
    public static Vector3I operator +(Vector3 Self, Vector3I Other) => new(Self.X + Other.X, Self.Y + Other.Y, Self.Z + Other.Z);
    public static Vector3I operator -(Vector3 Self, Vector3I Other) => new(Self.X - Other.X, Self.Y - Other.Y, Self.Z - Other.Z);

    public static Vector3 operator +(Vector3 Self, Vector3 Other) => new(Self.RawX + Other.RawX, Self.RawY + Other.RawY, Self.RawZ + Other.RawZ);
    public static Vector3 operator -(Vector3 Self, Vector3 Other) => new(Self.RawX - Other.RawX, Self.RawY - Other.RawY, Self.RawZ - Other.RawZ);

    public static Vector3 operator /(Vector3 Self, float Divisor)
    {
        long ConvertedDivisor = FloatToLong(Divisor); //Convert Divisor to long so it is deterministic 
        if (ConvertedDivisor == 0) return Self;
        //Shift Left before the division for all of the raw values
        return new((Self.RawX << Shift) / ConvertedDivisor, (Self.RawY << Shift) / ConvertedDivisor, (Self.RawZ << Shift) / ConvertedDivisor); 
    }

    public static Vector3 operator *(Vector3 Self, float Multiplier)
    {
        long ConvertedMultiplier = FloatToLong(Multiplier);
        //Multiply before the multiplication for all raw values
        return new((Self.RawX * ConvertedMultiplier) >> Shift, (Self.RawY * ConvertedMultiplier) >> Shift, (Self.RawZ * ConvertedMultiplier) >> Shift);
    }
    public long MagnitudeSquared()
    {
        return (RawX * RawX) + (RawY * RawY) + (RawZ * RawZ);
    }
    public Vector3 MoveToward(Vector3 to, float delta)
    {
        Vector3 vector = this;
        Vector3 vector2 = to - vector;
        float num = vector2.MagnitudeSquared();
        if (num <= delta || num < 1E-06f)
        {
            return to;
        }
        return vector + vector2 / num * FloatToLong(delta);
    }
}

}

I tried commenting it my self so I could understand it but I gave up trying to understand why I shift during specific math operations. If any one could help me or point me to a source I would be very greatfull! From what I can understand is that longs have a wider range of numbers because it's 64 bits and we use that instead of ints because it's 32 and I think the idea behind this is to use longs to and bit shifting to make it left it makes the number bigger. But shifting it right makes it smaller. Also I'm am supposed to shifting during specific math operations but I also don't understand why we shift before or after certian parts of it. Specificly for multiplication and division. Other than that I don't know what I'm doing


LorenzoGatti
LorenzoGatti

Mr Quivy wrote:

I don't under stand how fighting games are able to be made and be deterministic at the same time. For example how am I supposed to keep track of the players position without using float!? Let alone accelerate them at a fixed reasonable speed?

If you are afraid of floating point making the game nondeterministic, you can do the whole simulation in fixed point, and possibly in two dimensions, then convert to floating point numbers only for rendering. For example, after you place the reference point of a 2D character at integer coordinates (12,34) you are ready to multiply (12.0, 34.0, 0.0, 1.0) with an appropriate transformation matrix.

Note that arbitrary ordering of processing (e.g. A hits B and the game forgets that B hits A in the same frame because hits from A were processed first) or timing fluctuations of inputs and concurrent execution (e.g. the character jumps 1 frame later because the input was acquired 1 frame later) can make the game nondeterministic regardless of numerical issues.

Omae Wa Mou Shindeiru
JoeJ
JoeJ

Mr Quivy wrote:

I don't know how I would do this and I really need help. I used gemini to help me write a vector3 fixed point class but I'm still not confident that this is good enough to use it.

Sounds you need a primer about how integer math works on the bit level. Solid understanding is needed to deal with the primary limitation of fixed point math, the compromise between large numbers vs. high precision.

Let's say we are really limited by hardware, and we choose a format of 8 bits for both the integer and fraction bits.
And i'll use hexadecimal numbers for the example. This makes it much easier to understand because each hexadecimal digit represents 4 bits. In case you don't know, the '0x' prefix is just here to declare this number is hexadecimal, not decimal as usual.

Representing the number one in our format gives: 0x0100.
We have the integer part of 01 in the 8 higher bits and a fraction of 00 in the 8 lower bits.

Doing addition and subtraction is easy: 0x0100 + 0x0100 = 0x0200.
It just works and we do not any bit shifting. We can just use the native instructions.

But multiplication is more complicated. If we calculate 1 x 1 = 1, we get: 0x0100 x 0x0100 = 0x10000.
So we need to scale the result to preserve our chosen format,
which we can do with a right shift about 8 bits: 0x10000 >> 8 = 0x0100.
This gives us the correct result of 1, or 0x0100 in our format.

(I hope you can follow the example. But if not you miss some low level basics about computers, which is worth to study.)

Back to the example, here is the catch with our multiplication.
We got a multiplication result of 0x10000, which exceeds our chosen range. The largest number we can represnt in our 16 bit format is 0xFFFF, which one less than 0x10000. So, if our hardware uses 16bit numbers, which is likely the reason we picked a 16bit format in the first place, we get an overflow error and the result becomes zero in this case.

The generated code you got minimizes this problem by using 64 bit numbers, assuming that's what 'long' in C-sharp means. But you can still hit the ceiling at some point, and if so you may want to scale your numbers down, or you may use less bits for fractional but more for integer parts.

I also see a mixed use of integer and floating point numbers in the code. So i guess there goes your determinism, and all the effort on fixed point might be just a burden for no benefit, and you could just use native floating point instead.

Sadly i lack experience regarding floating point differnce across hardware, but maybe using floats would be still good enough and indeed the better choice?


frob
frob

Mr Quivy wrote:

I don't under stand how fighting games are able to be made and be deterministic at the same time. For example how am I supposed to keep track of the players position without using float!? Let alone accelerate them at a fixed reasonable speed? I don't know how I would do this and I really need help. I used gemini to help me write a vector3 fixed point class but I'm still not confident that this is good enough to use it.

Depending on the level of determinism you need, even that may not be enough. Usually people push for deterministic results when they're looking at network games, and then you need to make sure that operations are identical including done in the same order on both machines.

Very few games even attempt to use deterministic simulations. Those that do tend to work in deterministic lockstep, some of the classics like Warcraft, Starcraft, and AoE would transmit the commands for all units across the network, and when everyone knows what they're doing on the turn all would be applied simultaneously. Looking them up, Starcraft had about 15 ticks per second, in the original Warcraft it was 18.2 ticks per second. The relatively slow pace was masked by both animation and sounds, units immediately acknowledging the commands, then starting to play non-critical animations like a walking animation without actually moving the piece until the scheduled lockstep tick.

Deterministic code like that also has issues at the level of the compiler. Even if you write exactly the same code in Godot or C++ or C# or Go or JavaScript or Rust or whatever, the compiler can reorder code and even eliminate code if it doesn't think there are visible side effects, but unfortunately for you, tiny difference that the compiler doesn't care about can matter. Reordering certain operations can change the relative error in computation, and some known-problematic reordering of operations can cause catastrophic loss of precision, even though the mathematic results are well-defined as "correct"; they are "correct" but it also effectively puts half or more of the bits inside the error range, you may have gone from 6 decimal digits of precision to 2 decimal digits of precision. If you're using x86 FPU code rather than the SIMD registers there can even be hidden information you didn't know about or intend to be there, as the register is internally 80 bits even if you're only using 32, the remaining bits can be filled with stale data not just old from YOUR past math operations, but stale data from OUTSIDE YOUR PROGRAM because of operating system context switches, your program was switched back in, the value loaded, and now the FPU has subtly different (yet still mathematically in range) values that aren't bit-for-bit identical, therefore not deterministic. Similarly, if you're using the FPU rather than SIMD registers there are known differences between processors, because the mathematical definitions of the operations is based on a valid range, anything within the range is considered a valid result even when they are not bit-for-bit identical results.

But if you're still at the level of asking AI to write your code, you definitely aren't at the point where you are writing it. You can try, write it all using integer math and writing test code with a comprehensive automated test suite that ensures exactly identical results each time, including matching replay results. You'll likely learn a lot if you attempt it, so it can be good for a learning exercise even if it is unlikely to produce a deterministic gameplay simulation.

Sign in to reply to this topic.