Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

radiant_slice

A vertical slice of a tactical FPS in Unreal Engine 5: the Valorant/CS movement and gunplay model, written in C++ rather than assembled from Blueprints.

The interesting part of a shooter is not the shooting; it is that the client and the server disagree about where everyone is, and the game has to feel instant on a connection where it cannot be. This is an attempt at the systems that paper over that.

Status: barebones slice. These are the mechanics, not a game. There is no level, no HUD, no input bindings, no character mesh, and no .uproject. Source/ is C++ against the UE5 API, meant to be dropped into a project's RadiantSlice module. Nothing builds it in CI, since that needs the engine. See What isn't here.

The systems

Counter-strafing (Character/TacticalMovementComponent.cpp)

Tactical shooters are only accurate standing still, so stopping fast is the core skill. A custom UCharacterMovementComponent overrides CalcVelocity, the engine's per-move velocity update, and compares the direction of the current velocity with the direction of the input:

  • Input opposing momentum (dot < -0.2) → friction for that move rises to CounterStrafeFriction. While a key is held the engine uses friction to turn velocity toward the input, so tapping the opposite key stops you nearly dead.
  • Input roughly with momentum → ordinary ground friction. The engine applies no braking while a key is held, so strafing never scrubs speed.
  • No input at all → the engine's normal braking, a natural slide to a stop.

The choice happens inside the movement simulation, so the owning client's prediction and the server's replay of the same move agree and the server has nothing to correct. An earlier version set BrakingFrictionFactor from TickComponent on the local player only, which the engine never consults while a key is held.

Air control is clamped to 0.1 and acceleration set to 4000 for snappy grounded starts, which is the tactical-shooter feel rather than the arena-shooter one.

Recoil (Combat/HitscanRifle.cpp, Character/RadiantCharacter.cpp)

The spray pattern is a fixed table of where each of the 25 bullets lands relative to the crosshair when the trigger is held: the first shot goes where you aim, the next nine climb hard, and once the climb tops out the spray swings right, back across to the left, and right again while still creeping up. Fixed means learnable: pulling down and across against it is the skill.

  • Kicks, not offsets. Each shot adds the step to the next bullet's spot to a recoil rotation on the character. Bullets fire along the player's aim plus all of it, and GetViewRotation gives the camera half, so in a long spray the crosshair climbs and the bullets land above it, as they do in CS and Valorant.
  • Recovery. Recoil holds while the trigger is held, then returns to zero exponentially in time, so it settles the same way at 60 fps and at 240. The pattern winds back with rest as well, so a tap or a short burst starts again from the accurate first shots.
  • Spread on top. Random spread is a cone around that aim: 0.5 degrees for a first shot standing still, up to 6x while running, and a bloom that grows through a spray, capped at one extra degree. Standing still and tapping is what the numbers reward, without a hard accuracy rule.

tools/recoil_model.py steps the same logic frame by frame, reading the pattern and the tuning values out of the sources, since nothing here can run the engine. A held spray lands exactly on the table at both 60 and 240 fps, the crosshair peaks at 3.8 degrees and settles 0.87 seconds after the last shot, and taps half a second apart stay within 0.03 degrees of the crosshair.

Lag compensation (Combat/LagCompensationComponent.cpp)

The server records each character's head position every tick into a doubly linked list, trimmed to a 1-second rewind window. When a client reports a shot, GetHistoricalFrame walks back to the two frames bracketing the client's timestamp and lerps between them, reconstructing where the target was on the shooter's screen rather than where it is now on the server. Clients stamp each shot with GetServerWorldTimeSeconds(), the server's clock, which is the clock the history is recorded in.

This is why you can hit a target that has already run behind cover on your opponent's machine, and it is the reason to keep history server-side at all.

Server authority (Combat/HitscanRifle.cpp)

Firing is a client prediction plus a Server_ConfirmHit RPC (Server, Reliable, WithValidation). Damage is applied only on the authority. The client's trace is cosmetic. The weapon actor replicates, which an RPC needs to reach the server at all.

Abilities work the same way: Server_ExecuteAbility spends a charge on the server. Validation failures disconnect a client, so the charge and cooldown checks live in the implementation, which ignores an early press rather than kicking the player for it.

Layout

Source/Character/RadiantCharacter.*          pawn, first-person camera, recoil, head socket
Source/Character/TacticalMovementComponent.* counter-strafe friction model
Source/Combat/RadiantWeaponBase.*            ammo, fire rate, reload
Source/Combat/HitscanRifle.*                 spray pattern, spread, server RPC
Source/Combat/LagCompensationComponent.*     per-tick history and rewind
Source/Abilities/                            ability base + NearSight, DarkCover
Source/Core/                                 game mode, game state, player controller
Source/RadiantSlice.Build.cs                 module rules: the engine modules the code needs
tools/recoil_model.py                        the recoil logic stepped outside the engine

What isn't here

Honest accounting of the gap between the README and the code:

  • The rewind is not wired into hit validation. LagCompensationComponent records and interpolates correctly, but Server_ConfirmHit_Implementation applies damage to the client-reported target directly instead of calling GetHistoricalFrame and re-tracing against the rewound position. That connection is the actual point and it is the next thing to build.
  • HeadshotMultiplier is declared and never applied. Damage is flat BaseDamage, even though head position is exactly what the history tracks.
  • Server_ConfirmHit_Validate returns true unconditionally. A real build has to bound the client's timestamp against the rewind window and sanity-check the trace length, or a client can claim any hit at any time.
  • No input bindings. SetupPlayerInputComponent is an empty hook; Enhanced Input actions have to be set up in the editor.
  • No project files. No .uproject, content, maps, or meshes; only the module's RadiantSlice.Build.cs.
  • Abilities have no visible effect. Ability_NearSight finds its targets and reaches each victim's own machine through ARadiantCharacter::Client_ApplyNearsight, but the blackout is not built. Ability_DarkCover spawns whatever payload class is assigned, and there is no smoke asset.

License

MIT. See LICENSE.

Author

Abir Deol · abirdeol.tech

About

Tactical FPS systems in Unreal Engine 5 C++: counter-strafe movement, deterministic recoil, lag-compensation rewind and abilities.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages