HEXUN.ZHANG
HomeWorksAbout

Tank Combat

Listen-Server MultiplayerGameplay Ability SystemUnreal Engine C++ & Blueprint

Tank Combat is a two-player tank duel over the local network, built in Unreal Engine as a modern take on Atari's Combat (1977). One player hosts, the other finds the game from the main menu, and the two fight across a top-down arena with three bullet types and a speed-boost pickup. Underneath, firing and power-ups run on the Gameplay Ability System, bullets come from a server-side object pool, and the host decides every outcome and replicates it to the client.

ToolsUnreal Engine
TechC++, Blueprint, Gameplay Ability System
RoleSolo Developer
Timeline2026 January - April
Scroll to discover

Game Overview

LAN Sessions

One player hosts from the main menu; the other searches the local network and joins from a session list that shows each host's ping.

Server-Authoritative Play

The host decides movement, hits and scoring. The client sends its input and draws whatever the server replicates back.

Three Bullet Types

Standard, Guided and Bouncing bullets, each tied to its own game mode.

Speed Boost

A pickup doubles move and turn speed for five seconds through a timed Gameplay Effect.

Hits & Respawns

A hit scores a point, respawns the enemy tank and freezes both tanks for three seconds before play resumes.

Timed Matches

Matches run against a countdown clock, after which hits stop scoring. Arena and tank colors come from a data-table palette set by the server and replicated through the Game State.

Joining a Game

Sessions run on Unreal's Online Subsystem Null, which handles LAN discovery without a platform backend. The host creates a session from the main menu; the client searches, picks the host from the list and travels straight into the match. A small C++ function-library helper reads each result's ping for the list.

Client: Find Session → join the host → into the match

Architecture at a Glance

C++ holds the pieces Unreal only exposes to native code, such as the world subsystem and the replicated attribute set, plus the shared math. Blueprint composes them into the game's rules.

C++

Engine-level systems
  • UTankCombatObjectPool

    World subsystem that pre-spawns and recycles bullets. Server-only.

  • IPoolable

    Interface every pooled actor implements: ActivateFromPool / DeactivateToPool.

  • UTankAbilityAttributeSet

    Replicated MoveSpeed and RotationSpeed attributes.

  • ATankCombatPawn

    Collision root and movement component; forwards attribute changes to Blueprint.

  • ATankCombatBullet

    Replicated, always-relevant bullet base with the wall-reflection math.

  • GetSessionPing

    Function-library helper that reads a LAN session's ping for the server list.

Blueprint

Game rules
  • BP_TankCombatPawn

    Input, server-authoritative movement, firing and the post-hit freeze.

  • BP_TankCombatBullet (+ Guided, Bouncing)

    Bullet lifecycle and replicated activation.

  • GA_FireBullet · GE_BoostSpeed · BP_BoostSpeed

    The fire ability, the boost effect and its pickup.

  • Game Modes: Standard, Guided, Bouncing

    Bullet type and pool sizes; player login and spawning.

  • Game State · Player State

    Match state and color palette; replicated score.

  • Game Instance & Menu

    Hosting, finding and joining LAN sessions (Online Subsystem Null).

Networked Multiplayer

The game runs on a listen server: the host's machine is both the server and a player. Every gameplay decision happens there; the client sends input and draws what the server replicates. Two bugs taught me most of what this section is about.

Server-Authoritative Movement

The client never simulates its own tank. Held input goes to the server through a reliable Server Move RPC, which stores it as the tank's current input. On its tick, the server moves each tank from that input and broadcasts the result with a multicast RPC; machines without authority apply the transform directly.

Flow:

  1. IA_Move → Server Move (client → server)
  2. Server tick → Handle Movement
  3. Multicast Update Transform (server → everyone)
  4. Remote machines set location and rotation
Tank movement replication Blueprint graph
BP_TankCombatPawn: Tank Movement System

Bug: The Invisible Host Tank

HostClient
Before: the host sees both tanks; the client never receives the host's blue tank
HostClient
After: with Always Relevant on the pawn, both tanks appear on both screens

In two-player tests, the client couldn't see the host's tank at all, while the host saw both. The client's own tank was fine, and so were the bullets.

Unreal only replicates an actor to a client while it is relevant to that client, and by default relevancy is a distance check from the client's viewpoint. This game uses a fixed, orthographic top-down camera placed far from the arena, so measured from the client's viewpoint, the host's tank was always beyond the cull distance and never replicated. The client's own tank escaped because an actor is always relevant to its owner; bullets escaped because their C++ constructor already sets bAlwaysRelevant.

The fix was to mark the tank pawn Always Relevant. With two players in one arena, every tank has to reach every client anyway, so distance culling had nothing to save.

TankCombatBullet.cpp
ATankCombatBullet::ATankCombatBullet(){    PrimaryActorTick.bCanEverTick = true;    bReplicates = true;    bAlwaysRelevant = true;}

Bug: The Slow Client

HostClient
Before: on both screens, the host's tank (bottom) outruns the client's (top)
HostClient
After: both tanks move at the same speed on both screens

Later, with both tanks driving side by side, the client's tank covered about half the ground the host's did, and both screens agreed. It wasn't a display desync; the server really was moving the client's tank less.

The cause was in the server tick: after moving the tanks, it reset the stored input to zero, treating each Server Move as a one-tick impulse. The host's own Server Move runs locally every frame, so the host always had fresh input. The client's RPCs cross the network and don't land exactly one per server tick, so some ticks had nothing new, and those ticks moved the client's tank by zero.

The server now keeps the last input until it's replaced, and releasing the key (Completed or Canceled) sends an explicit zero. Input became state instead of a per-tick event, and both tanks move at the same speed.

Movement graph before the fix
Before: the tick ends by clearing Current Tick Input
Movement graph after the fix
After: input persists; releasing the key sends zero

Anatomy of a Shot

"One bullet at a time" is a single gameplay rule, but enforcing it over the network runs through four systems: input, the Gameplay Ability System, the object pool and replication.

1

Input

Fire checks Is Actionable, then asks the Ability System Component to activate GA_FireBullet. Remote activation lets a client's request run on the server.

2

Ability

GA_FireBullet is blocked while the tank owns TankCombat.DisableFireBullet. With authority, it takes a bullet of the tank's type from the pool.

3

Activation

The pool calls ActivateFromPool. The bullet stores who fired it in a RepNotify struct, and OnRep_ActivationInfo sets it up on every machine.

4

Lock

On the server, the bullet adds TankCombat.DisableFireBullet to its tank as a replicated loose tag. The tank can't fire again.

5

Release

A wall, a tank or an expired lifetime returns the bullet to the pool. The server removes the tag, and replication clears it on the client.

The Rule Lives in Data

Firing is a Gameplay Ability rather than a plain function call, so the rule is configuration instead of code. GA_FireBullet lists TankCombat.DisableFireBullet as an Activation Blocked Tag: while the tank owns that tag, the ability system refuses to activate it.

The ability only does work where it has authority. On the server it takes a bullet of the tank's current type from the pool and ends; anywhere else it ends without spawning anything.

GA_FireBullet tag configuration
GA_FireBullet: Tags
Fire input Blueprint graph
BP_TankCombatPawn: fire input
GA_FireBullet ActivateAbility graph
GA_FireBullet: ActivateAbility
Gameplay Debugger: while a bullet is in flight, the tank owns TankCombat.DisableFireBullet and GA_FireBullet shows as blocked

A Server-Side Object Pool

Bullets are never spawned or destroyed mid-match. A UWorldSubsystem pre-spawns each game mode's bullets on the server when the match starts, marks them replicated and parks them until they're needed.

Anything poolable implements IPoolable, a C++ interface whose events are implemented in Blueprint. The pool drives Blueprint bullets through that interface without depending on their classes, and every entry point checks that it is running on the server, so a client can never hand out or reclaim a bullet.

TankCombatObjectPool.h
UINTERFACE(Blueprintable)class UPoolable : public UInterface{    GENERATED_BODY()}; class IPoolable{    GENERATED_BODY() public:    UFUNCTION(BlueprintCallable, BlueprintNativeEvent)    void ActivateFromPool(AActor* Activator);     UFUNCTION(BlueprintCallable, BlueprintNativeEvent)    void DeactivateToPool();};
TankCombatObjectPool.cpp (logging trimmed)
AActor* UTankCombatObjectPool::GetFromPool(    TSubclassOf<AActor> ClassType, AActor* Activator){    // Only the server should hand out actors from the pool    if (!IsServerSide()) return nullptr;     AActor* FoundActor = nullptr;    if (Pool.Contains(ClassType) &&        ClassType->ImplementsInterface(UPoolable::StaticClass()))    {        if (Pool[ClassType].Num() > 0)        {            FoundActor = Pool[ClassType].Pop(true);            IPoolable::Execute_ActivateFromPool(FoundActor, Activator);        }    }    return FoundActor;} bool UTankCombatObjectPool::IsServerSide() const{    UWorld* World = GetWorld();    if (!World) return false;    return World->GetNetMode() != NM_Client;}

Activation Is State, Not an Event

The pool's events don't set the bullet up directly. They write an ActivationInfo struct, holding whether the bullet is live and which tank fired it, into a RepNotify variable. OnRep_ActivationInfo runs on the server when the value is set and on the client when it arrives, then routes to ActivateBullet or DeactivateBullet.

Inside those functions, Switch Has Authority splits the work. The server positions the bullet and adds or removes the fire-lock tag; every machine updates the bullet's visibility and collision. The tag is a loose gameplay tag added with Should Replicate, so the client's copy of the tank carries the same lock as the server's.

Pool events writing ActivationInfo
Pool events write ActivationInfo
ActivationInfo variable details
Replication: RepNotify
OnRep_ActivationInfo dispatch graph
OnRep_ActivationInfo
DeactivateBullet Blueprint graph
DeactivateBullet: server-only reset and tag removal (top), every machine (bottom)

Speed Boost

The pickup is the attribute side of GAS: a timed Gameplay Effect changing replicated attributes.

MoveSpeed 2000 → 4000 while GE_BoostSpeed is active, back to base after 5 seconds
RotationSpeed 90 → 180 under the same effect

The Effect

GE_BoostSpeed has a five-second duration and two Multiply (Compound) ×2 modifiers, one on MoveSpeed and one on RotationSpeed. When it expires, GAS removes the modifiers and the attributes fall back to their base values, with no timer code of my own.

Rotating the box-shaped tank on the edge of the pickup's trigger kept re-entering the overlap and stacking the effect. A short cooldown on the pickup fixed it.

GE_BoostSpeed details
GE_BoostSpeed: duration and modifiers

The Attributes

Attribute sets can only be declared in C++. MoveSpeed and RotationSpeed replicate with REPNOTIFY_Always, so the client's values follow the server's.

The pawn subscribes to MoveSpeed changes in C++ and re-broadcasts them through a BlueprintAssignable delegate, so the Blueprint movement code reacts to the new speed without polling.

TankCombatPawn.cpp
void ATankCombatPawn::BeginPlay(){    Super::BeginPlay();     // Ask GAS to find the Ability System Component on this Actor    UAbilitySystemComponent* ASC =        UAbilitySystemGlobals::GetAbilitySystemComponentFromActor(this);    if (ASC)    {        ASC->GetGameplayAttributeValueChangeDelegate(            UTankAbilityAttributeSet::GetMoveSpeedAttribute()        ).AddUObject(this, &ATankCombatPawn::HandleMoveSpeedChanged);    }} void ATankCombatPawn::HandleMoveSpeedChanged(    const FOnAttributeChangeData& Data){    // Broadcast the new value to your Blueprint Event Graph    OnMoveSpeedChanged.Broadcast(Data.NewValue);}
TankAbilityAttributeSet.cpp
void UTankAbilityAttributeSet::GetLifetimeReplicatedProps(    TArray<FLifetimeProperty>& OutLifetimeProps) const{    Super::GetLifetimeReplicatedProps(OutLifetimeProps);     // REPNOTIFY_Always ensures the client updates even if it    // tries to predict the value locally    DOREPLIFETIME_CONDITION_NOTIFY(UTankAbilityAttributeSet,        MoveSpeed, COND_None, REPNOTIFY_Always);    DOREPLIFETIME_CONDITION_NOTIFY(UTankAbilityAttributeSet,        RotationSpeed, COND_None, REPNOTIFY_Always);} // Fires on the client when the Server sends a new MoveSpeedvoid UTankAbilityAttributeSet::OnRep_MoveSpeed(    const FGameplayAttributeData& OldMoveSpeed){    GAMEPLAYATTRIBUTE_REPNOTIFY(        UTankAbilityAttributeSet, MoveSpeed, OldMoveSpeed);}

Bullet Types

Each game mode decides which bullet class the tanks fire and how many the pool pre-spawns. All three share the same pooled, replicated lifecycle.

Standard · Guided · Bouncing

Standard

Flies straight from the tank's fire pivot and returns to the pool when it hits a wall or a tank, or when its lifetime runs out.

Guided

Every tick, its velocity is overwritten with the firing tank's current facing, so the player steers the shot by turning.

Bouncing

Reflects off walls instead of returning to the pool, using a C++ helper that mirrors the velocity across the hit normal.

Results and Takeaways

Project Outcome

A packaged build that two machines on the same network can host and join, with all three bullet modes and the speed-boost pickup.

Watch the full gameplay video →

What I Took Away

Relevancy Follows the Viewer

Relevancy is measured from the client's viewpoint. With a fixed camera far from the action, distance culling hid an actor that was plainly on screen.

Hold Input as State

Consuming input once per server tick only works if exactly one RPC lands per tick. Over a real connection it doesn't, so the server should keep the latest input until it's replaced.

Replicate State, Not Moments

Bullet activation and the fire lock both live in replicated state, so every machine that receives a bullet ends up in the same place.

Future Improvements

Lighter Network Traffic

Server Move and the transform multicast are both reliable and sent every frame or tick. Now that the server holds input, the client only needs to send it when it changes, and the transform could move to unreliable updates.

Client-Side Prediction

The client's own tank waits for the server's transform before it moves. Predicting local movement and reconciling with the server would hide latency beyond a LAN.

Credits

Built for a game development course at Duke University. The arena ground and layout textures came with the course starter content and recreate the playfields of Atari's Combat (1977).