Hit Prediction
This is an Open Source implementation of the Hit Prediction for UT2003, which is explained at Lag Compensation.
Feel free to use and improve the code for whatever you want, but if you modify it, please let us know about the modifications.
I don't offer a download here because I don't think that the implementation is polished and "foolproof" enough yet.
This example implementation will use a modified Instagib gametype with only one modified gun.
Sourcecode
To make the source managable, I will post it in chunks and not in one whole, with a discussion for each chunk.
The LocationTrail class
class LocationTrail extends Object; var float TimeStamp; var vector Location; var Rotator Rotation; var LocationTrail Next;
Simply a class to hold a trail node.
The Game Replication class
class PredictionReplicationInfo extends GameReplicationInfo; var float PackageTimeStamp; // Constantly replicated to the client to TimeStamp shot packages for prediction replication { reliable if ( bNetDirty && (Role == ROLE_Authority) ) PackageTimeStamp; } function Tick( float DeltaTime) { PackageTimeStamp = Level.TimeSeconds; Super.Tick(DeltaTime); }
Discussion
Spark: See lag compensation for a discussion why it's neccessary to replicate the servertime constantly. It might be desirable to find a better solution though...
The Player class
class PredictedPlayer extends xPlayer; event PlayerTick( float DeltaTime ) { Super.PlayerTick(DeltaTime); // Increase PackageTimeStamp for smooth interpolated hit prediction PredictionReplicationInfo(GameReplicationInfo).PackageTimeStamp += DeltaTime; }
Discussion
Spark: This is simply because a client will usually run at a higher FPS, so prediction isn't limited to the ~50ms steps of the server (a client will predict movement between those 50ms steps).
The Pawn class
class PredictedPawn extends xPawn; var LocationTrail LocationTrail; // The actual trail of locations var vector SavedLocation; // Used to restore location after timeshifting var Rotator SavedRotation; var float SavedTimeStamp; var float MaxTrailSeconds; // How many seconds should be stored in trails event Tick( float DeltaTime ) { local LocationTrail Trail; Super.Tick(DeltaTime); // Store the new position and rotation in a LocationTrail Trail = new (none) class'LocationTrail'; Trail.Next = LocationTrail; LocationTrail = Trail; LocationTrail.Location = Location; LocationTrail.Rotation = Rotation; // Using the timestamp which was saved at roundbegin and will be sent to the client LocationTrail.TimeStamp = PredictionReplicationInfo(Level.Game.GameReplicationInfo).PackageTimeStamp; // Free outdated LocationTrails for ( Trail=LocationTrail; Trail!=None; Trail=Trail.next ) { if ( Level.TimeSeconds - Trail.TimeStamp > MaxTrailSeconds ) { Trail.Next = None; break; } } } defaultproperties { MaxTrailSeconds=0.4; }
Discussion
Spark: I'm not sure if Tick() is the best place to store the trails. But it works and is pretty simple. It might be smarter to do this directly after playermovement though, but I couldn't find a place to hook this in (there probably is none, because actor movement is native).
I use PackageTimeStamp for the timestamp because I wasn't sure weither ServerTime gets modified during a tick. It probably doesn't, in this case it would make no difference to use Level.ServerTime instead.
The Weapon class
We need to modify each weapon that is supposed to be unlagged, so we need a new weaponclass. Should we need a lot of unlagged weapons, it would make sense to create a new baseclass but for this example there will be only one gun (a modified EnhancedShockrifle).
class PredictedShockRifle extends SuperShockRifle; replication { // functions called by client on server reliable if( Role<ROLE_Authority ) ServerPredictedStartFire; } //// client only //// simulated event ClientStartFire(int Mode) { if (Pawn(Owner).Controller.IsInState('GameEnded')) return; if (Role < ROLE_Authority) { if (StartFire(Mode)) { ServerPredictedStartFire( Mode, PredictionReplicationInfo(PlayerController(Instigator.Controller).GameReplicationInfo).PackageTimeStamp ); } } else { StartFire(Mode); } } //// server only //// event ServerPredictedStartFire(byte Mode, float ClientTimeStamp) { if ( (FireMode[Mode].NextFireTime <= Level.TimeSeconds + FireMode[Mode].PreFireTime) && StartFire(Mode) ) { FireMode[Mode].ServerStartFireTime = Level.TimeSeconds; PredictedShockBeamFire(FireMode[Mode]).ClientStartFireTime = ClientTimeStamp; // Stupid hack but it works as long as we can trust the FireMode property. Clean code would check if this really is a PredictedShockBeamFire. FireMode[Mode].bServerDelayStartFire = false; } else FireMode[Mode].bServerDelayStartFire = true; } defaultproperties { FireModeClass(0)=PredictedShockBeamFire; }
Discussion
Spark: I decided to create a new ServerStartFire function, so the timestamp can be transmitted. ClientStartFire is simply modified to call the new ServerStartFire.
The WeaponFire class
This is a huge one, so I split it up in even smaller pieces.
class PredictedShockBeamFire extends SuperShockBeamFire; // --- Prediction --- var float ClientStartFireTime; // This is the time the client actually started shooting function DoTrace(Vector Start, Rotator Dir) { local Vector X, End, HitLocation, HitNormal, RefNormal; local Actor Other; local int Damage; local bool bDoReflect; local int ReflectNum; ReflectNum = 0; while (true) { bDoReflect = false; X = Vector(Dir); End = Start + TraceRange * X; // *** HIT PREDICTION *** // Timeshift all pawns to make them match the clients reality (hopefully) before doing the trace if ( ClientStartFireTime > 0 ) TimeShiftPawns(ClientStartFireTime); Other = Trace(HitLocation, HitNormal, End, Start, true); // Revert all pawns to their original position if ( ClientStartFireTime > 0 ) UnShiftPawns(); if ( Other != None && (Other != Instigator || ReflectNum > 0) ) { if (bReflective && Other.IsA('xPawn') && xPawn(Other).CheckReflect(HitLocation, RefNormal, DamageMin*0.25)) { bDoReflect = true; HitNormal = Vect(0,0,0); } else if (!Other.bWorldGeometry) { Damage = (DamageMin + Rand(DamageMax - DamageMin)) * DamageAtten; Other.TakeDamage(Damage, Instigator, HitLocation, Momentum*X, DamageType); HitNormal = Vect(0,0,0); } else if ( WeaponAttachment(Weapon.ThirdPersonActor) != None ) WeaponAttachment(Weapon.ThirdPersonActor).UpdateHit(Other,HitLocation,HitNormal); } else { HitLocation = End; HitNormal = Vect(0,0,0); } // Not here, we will do it clientside. :) // SpawnBeamEffect(Start, Dir, HitLocation, HitNormal, ReflectNum); if (bDoReflect && ++ReflectNum < 4) { //Log("reflecting off"@Other@Start@HitLocation); Start = HitLocation; Dir = Rotator(RefNormal); //Rotator( X - 2.0*RefNormal*(X dot RefNormal) ); } else { break; } } }
The only important difference here is, that we timeshift all pawns before we do the hit detection trace. Thsi will get more clear later but it's really the most important thing and why we do all this in fact. This will do the hit detection in the timeframe the client saw when triggering the shot.
// Do the beameffect clientside function DoClientTrace(Vector Start, Rotator Dir) { local Vector X, End, HitLocation, HitNormal, RefNormal; local Actor Other; local bool bDoReflect; local int ReflectNum; ReflectNum = 0; while (true) { bDoReflect = false; X = Vector(Dir); End = Start + TraceRange * X; Other = Trace(HitLocation, HitNormal, End, Start, true); if ( Other != None && (Other != Instigator || ReflectNum > 0) ) { if (bReflective && Other.IsA('xPawn') && xPawn(Other).CheckReflect(HitLocation, RefNormal, DamageMin*0.25)) { bDoReflect = true; HitNormal = Vect(0,0,0); } else if (!Other.bWorldGeometry) { HitNormal = Vect(0,0,0); } } else { HitLocation = End; HitNormal = Vect(0,0,0); } SpawnBeamEffect(Start, Dir, HitLocation, HitNormal, ReflectNum); if (bDoReflect && ++ReflectNum < 4) { //Log("reflecting off"@Other@Start@HitLocation); Start = HitLocation; Dir = Rotator(RefNormal); //Rotator( X - 2.0*RefNormal*(X dot RefNormal) ); } else { break; } } } function DoClientFireEffect() { local Vector StartTrace; local Rotator R, Aim; Instigator.MakeNoise(1.0); // the to-hit trace always starts right in front of the eye StartTrace = Weapon.Owner.Location + Pawn(Weapon.Owner).EyePosition(); Aim = AdjustAim(StartTrace, AimError); R = rotator(vector(Aim) + VRand()*FRand()*Spread); DoClientTrace(StartTrace, R); } event ModeDoFire() { Super.ModeDoFire(); // client if (Instigator.IsLocallyControlled()) { DoClientFireEffect(); } }
This will just render the shockbeam at the clientside. Not sure if it's the best way to do it, as it was just a quick hack to make it work.
// *** Hit Prediction Timeshifting Functions *** function TimeShiftPawns(float TimeStamp) { local Controller C; local PredictedPawn P; local LocationTrail CurrentTrail, LateTrail, OldTrail; local float interp; local vector LerpedLocation; local Rotator LerpedRotation; for ( C=Level.ControllerList; C!=None; C=C.NextController ) { // Do not shift the attacker because she should already be where she was when she did the shot. if ( PredictedPawn(C.Pawn)!=None && C.Pawn != Weapon.Owner ) P = PredictedPawn(C.Pawn); else continue; LateTrail = None; OldTrail = None; // Find the sandwitching trail timestamps for ( CurrentTrail=P.LocationTrail; CurrentTrail!=None; CurrentTrail=CurrentTrail.Next ) { if ( CurrentTrail.TimeStamp > TimeStamp ) LateTrail = CurrentTrail; else if ( OldTrail == None ) OldTrail = CurrentTrail; else break; } if ( LateTrail != None && OldTrail != None ) { // Save original location P.SavedLocation = P.Location; P.SavedRotation = P.Rotation; P.SavedTimeStamp = Level.TimeSeconds; // Interpolate between closest trails and move pawn to this location // Find alpha value for interpolation based on timestamps interp = ( TimeStamp - OldTrail.TimeStamp ) / ( LateTrail.TimeStamp - OldTrail.TimeStamp ); interp = FClamp( interp, 0, 1 ); //Log("OldTimeStamp: "$OldTrail.TimeStamp$", LateTimeStamp: "$LateTrail.TimeStamp$", TimeStamp: "$TimeStamp); //Log("Resulting Alpha: "$interp); LerpedLocation.X = Lerp( interp, OldTrail.Location.X, LateTrail.Location.X ); LerpedLocation.Y = Lerp( interp, OldTrail.Location.Y, LateTrail.Location.Y ); LerpedLocation.Z = Lerp( interp, OldTrail.Location.Z, LateTrail.Location.Z ); //Log("OldLocation: "$OldTrail.Location$", LateLocation: "$LateTrail.Location$", LerpedLocation: "$LerpedLocation); LerpedRotation.Pitch = Lerp( interp, OldTrail.Rotation.Pitch, LateTrail.Rotation.Pitch ); LerpedRotation.Yaw = Lerp( interp, OldTrail.Rotation.Yaw, LateTrail.Rotation.Yaw ); LerpedRotation.Roll = Lerp( interp, OldTrail.Rotation.Roll, LateTrail.Rotation.Roll ); //Log("OldRotation: "$OldTrail.Rotation$", LateRotation: "$LateTrail.Rotation$", LerpedRotation: "$LerpedRotation); P.SetLocation( LerpedLocation ); P.SetRotation( LerpedRotation ); } else if ( LateTrail != None ) { // Save original location P.SavedLocation = P.Location; P.SavedRotation = P.Rotation; P.SavedTimeStamp = Level.TimeSeconds; // TimeStamp is out of reach, move pawn to latest trail P.SetLocation( LateTrail.Location ); P.SetRotation( LateTrail.Rotation ); } } } function UnShiftPawns() { local Controller C; local PredictedPawn P; for ( C=Level.ControllerList; C!=None; C=C.NextController ) { if ( PredictedPawn(C.Pawn)!=None && C.Pawn != Weapon.Owner ) P = PredictedPawn(C.Pawn); else continue; // Make sure that saved location is of this tick before moving if ( P.SavedTimeStamp == Level.TimeSeconds ) { P.SetLocation( P.SavedLocation ); P.SetRotation( P.SavedRotation ); } } }
And last but not least the actual timeshifting functions (if more than one weapon is used, those should be moved to a new common base class if possible).
Finally you need a new Gametype or Mutator to replace the Pawn and PlayerController with out custom ones, use the new GameReplicationInfo class and make sure that only our Instagib rifle is used (a modified version of the Instagib mutator will do). I don't post this here because it's a rather common task.
Discussion
Spark: That's it! I hope that those who actually use this code will actively help to improve the code, because a well working hit prediction could be quite important for mods trying to compete with the huge Counter-Strike (and for every other mod featuring hitscan weapons of course). It would be a pity if everyone would have to create this rather common functionality on his own, that's why I wrote this. Another cool thing would be a Mutator replacing all weapons with predicted counterparts that could be used for all basic gametypes. Maybe it would even be possible without subclassing Pawn and PlayerController, but I'm not sure.
Things that should be looked at and really need improvement are:
- Resetting the trails
- If a player respawns or teleports, the trail should be resetted, otherwise the interpolation would move the pawn at a place he never visited. The chance that this leads to an accidental hit is basically null, that's why I didn't bother yet but it's certainly crack. Another thing is, that the trails should probably be cleaned after a pawn is destroyed (not sure if this is automatic).
- Rapid fire guns
- Those guns in UT2003 only send StartFire and a StopFire signals, so with the above code, the ClientTimeStamp would also be replicated only once. This means that the ClientFireTime time has to be increased just like the ServerFireTime. This is not a problem (as long as you think of it), but it can lead to inaccuracies with flakey latency. How could this be solved? Maybe constantly replicate the current PackageTimeStamp to the server while shooting?
- Accuracy
- The current code seems to be pretty accurate, in tests I could hit reliable with pings up to 400. But still there might be inaccuracies so this should constantly be reviewed and ultimately it would be great to have some usefull debug functions, for example to show the hitbox or a client trace which checks weither you should have hit and then compares this to what the server says. There might be countless other improvements, but I think this is good for a start.
capt.k: I made a similar implementation for my UT mod. Haven't extensively tested this, so I dunno if it's any better or worse than what you've got above, but it's pretty much the same except for a couple things: I got around subclassing controller or pawn by using an inventory item which is given to each respawning player to manage each LocationTrail list. LocationTrail is an Actor, so it can now be destroyed on command, and in its Destroyed() it destroys the next link as well – the idea is that it insures that all LocationTrails are eventually removed. If the inventory item is destroyed, it destroys the head of the Trail list, and the head destroys all the children. I also had the LocationTrails only spawed via a timer at intervals of 0.02. Mainly for the sake of uniformity and so we know how many LocationTrails may exist at any given time (and to be consistent w/ Neil Toronto's implementation); again, I dunno if it's any better or worse, but I figure since the position is interpolated anyway, it's an acceptable tradeoff. Unfortunately, I wasn't astute enough to come up with a way to pass the client's timestamp to determine how far back to rewind position to, so instead I just used the player's ping. hth.
Spoon:
In "PredictedPawn", on the client side, "Level.Game.GameReplicationInfo" is "None" So on the client side you get an "access none" errors. To get rid of the errors, I just wrapped the code block with an "if( ROLE == ROLE_Authority)" so the client can not run the code.
note: I remove the other problem I was having due to a error on my part and not an error related to this code.
Related Topics
[Unlagged Tutorial] (A hit prediction implementation for Quake 3 Arena)