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'sRadiantSlicemodule. Nothing builds it in CI, since that needs the engine. See What isn't here.
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.
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
GetViewRotationgives 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.
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.
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.
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
Honest accounting of the gap between the README and the code:
- The rewind is not wired into hit validation.
LagCompensationComponentrecords and interpolates correctly, butServer_ConfirmHit_Implementationapplies damage to the client-reported target directly instead of callingGetHistoricalFrameand re-tracing against the rewound position. That connection is the actual point and it is the next thing to build. HeadshotMultiplieris declared and never applied. Damage is flatBaseDamage, even though head position is exactly what the history tracks.Server_ConfirmHit_Validatereturnstrueunconditionally. 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.
SetupPlayerInputComponentis 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'sRadiantSlice.Build.cs. - Abilities have no visible effect.
Ability_NearSightfinds its targets and reaches each victim's own machine throughARadiantCharacter::Client_ApplyNearsight, but the blackout is not built.Ability_DarkCoverspawns whatever payload class is assigned, and there is no smoke asset.
MIT. See LICENSE.
Abir Deol · abirdeol.tech