The Project

This is a solo university project I worked on during year 4. I am using Unreal Engine 4 and C++ to implement a brawler-style multiplayer game.
Please note that the game is still in a very early stage.

The Network

In my game, one of the players will be the listen server while the others are clients. I have created a Network class which will manage everything related to sessions. I have split the logic that is situation dependant into another abstract class called a NetworkEventSequence. For finding a match I inherit from that class and called the new class MatchFinder. This class will run the following network sequence: Find sessions -> Make an x amount of attempts to join the session -> If fails -> Try next session until last session. When no sessions are found, a new one will be created and can be joined by other players. The matchfinder class will use the methods in the network class to create this sequence. I split this functionality up because I imagined I would need other ways of joining sessions in the future (e.g. directly from a friend's list). To recap: the Network contains very generic helper methods and the different network event sequences determine how these methods are called.

The Network:


DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnNetworkEventSequenceStarted);
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnNetworkEventSequenceCompleted);

class APlayerController;
class UBbNetworkDatabase;
class UBbDebugDatabase;
class UBbNetworkEventSequence;

UENUM(BlueprintType)
enum class EBbNetworkLogType : uint8
{
	NLT_Info	UMETA(DisplayName = "Info"),
	NLT_Error	UMETA(DisplayName = "Error")
};

DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnNetworkLog, FString, Message, EBbNetworkLogType, LogType);

class BITBLITZ_API UBbNetwork : public UObject
{
	GENERATED_BODY()
public:
	UBbNetwork(const FObjectInitializer& ObjectInitializer);
	void Init();

public:
	/**	
	*	Entry call for all network related functionality
	*	@param NetworkEventSequenceClass The class used for determining what the network should do
	*	@param PlayerController The player controller passed to the network event sequence to be used for the session operations
	*/
	UFUNCTION(BlueprintCallable, Category = "Networking")
	void RunNetworkEventSequence(TSubclassOf<UBbNetworkEventSequence> NetworkEventSequenceClass, APlayerController* PlayerController);

	UFUNCTION(BlueprintPure, Category = "Networking")
	UBbNetworkEventSequence* GetCurrentNetworkEventSequence() const;

	UFUNCTION(BlueprintPure, Category = "Networking")
	bool IsRunning() const;

private:
	void IncrementPendingOperations();
	void DecrementPendingOperations();

	//////////////////////////////////////////////////////////////////////////
	// Finding sessions
	//////////////////////////////////////////////////////////////////////////
public:
	/**
	*	Attempts to find existing sessions
	*/
	void FindSessions();
private:
	UFUNCTION()
	void OnFindSessionsComplete(bool bWasSuccess);

public:
	void CancelFindSessions();

private:
	UFUNCTION()
	void OnCancelFindSessionsComplete(bool bWasSuccess);

	//////////////////////////////////////////////////////////////////////////
	// Creating/Starting sessions
	//////////////////////////////////////////////////////////////////////////
public:
	/**
	*	Attempts to create a new session
	*/
	void CreateSession();
	int32 GetCreateSessionAttempt() const;

private:
	UFUNCTION()
	void OnCreateSessionComlete(FName SessionName, bool bWasSuccess);
	UFUNCTION()
	void OnStartSessionComplete(FName SessionName, bool bWasSuccess);

	//////////////////////////////////////////////////////////////////////////
	// Joining sessions
	//////////////////////////////////////////////////////////////////////////
public:
	/**
	*	Attempts to join the session
	*	@param SearchResult the result used to determine which session to join
	*/
	void JoinSession(const FOnlineSessionSearchResult& SearchResult);
	int32 GetJoinSessionAttempt() const;
private:
	void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result);

	//////////////////////////////////////////////////////////////////////////
	// Destroying sessions
	//////////////////////////////////////////////////////////////////////////
public:
	/**
	*	Attempts to destroy the session the given player controller owns
	*/
	void DestroySession();

	/**
	*	Attempts to destroy the session of given SessionName
	*	@param SessionName Name of the session to destroy
	*/
	void DestroySessionByName(FName SessionName);
private:
	void OnDestroySessionComplete(FName SessionName, bool bWasSuccess);

public:
	UPROPERTY(BlueprintAssignable, Category = "Networking")
	FOnNetworkEventSequenceCompleted OnNetworkEventSequenceCompleted;

	UPROPERTY(BlueprintAssignable, Category = "Networking")
	FOnNetworkEventSequenceStarted OnNetworkEventSequenceStarted;

	UPROPERTY(BlueprintAssignable, Category = "Networking")
	FOnNetworkLog OnNetworkLog;

	UPROPERTY(BlueprintReadOnly, Category = "Networking")
	bool bIsAborting;

private:
	UPROPERTY()
	int32 CreateSessionAttemptCount;

	UPROPERTY()
	int32 JoinSessionAttemptCount;

	int32 PendingOperations;

	UPROPERTY()
	UBbNetworkEventSequence* NetworkEventSequence;

	UBbNetworkDatabase* NetworkDatabase;
	UBbDebugDatabase* DebugDatabase;

	FOnDestroySessionCompleteDelegate OnDestroySessionCompleteDelegate;
	FOnFindSessionsCompleteDelegate OnFindSessionsCompleteDelegate;
	FOnCancelFindSessionsCompleteDelegate OnCancelFindSessionsCompleteDelegate;
	FOnCreateSessionCompleteDelegate OnCreateSessionCompleteDelegate;
	FOnStartSessionCompleteDelegate OnStartSessionCompleteDelegate;
	FOnJoinSessionCompleteDelegate OnJoinSessionCompleteDelegate;

	FDelegateHandle OnCancelFindSessionsCompleteDelegate_Handle;
	FDelegateHandle OnFindSessionsCompleteDelegate_Handle;
	FDelegateHandle OnCreateSessionCompleteDelegate_Handle;
	FDelegateHandle OnStartSessionCompleteDelegate_Handle;
	FDelegateHandle OnJoinSessionCompleteDelegate_Handle;
	FDelegateHandle OnDestroySessionCompleteDelegate_Handle;
};

Network Event Sequence base class:


class UBbNetworkDatabase;
class UBbNetwork;

/**
 * 
 */
UCLASS(Abstract)
class BITBLITZ_API UBbNetworkEventSequence : public UObject
{
	GENERATED_BODY()
	
public:
	void Init(UBbNetwork* ParamNetwork, APlayerController* ParamPlayerController);
	
	virtual void Run();

	UFUNCTION(BlueprintCallable, Category = "Network Event Sequence")
	virtual void Abort();

	virtual void OnFindSessionsComplete(bool bWasSuccess);
	virtual void OnStartSessionComplete(FName SessionName, bool bWasSuccess);
	virtual void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result);
	virtual void OnDestroySessionComplete(FName SessionName, bool bWasSuccess);
	
	/**
	*	Returns the player controller currently used for the network  event sequence (can be nullptr)
	*/
	APlayerController* GetPlayerController() const;
	
public:
	TSharedPtr<FOnlineSessionSearch> SearchObject;

protected:
    UPROPERTY()
	UBbNetworkDatabase* NetworkDatabase;
    
    UPROPERTY()
	UBbNetwork* Network;
    
    UPROPERTY()
	APlayerController* PlayerController;
};

A network event sequence can easily be run like this:

State Machines

For managing different character logic and animations I have created a State Machine in C++. The state machine is an actor component that can be used generically for any type of character. My character base class contains 2 state machine components, one for the lower body and one for the upper body. I will refer to these state machines as the primary and secondary state machine respectively.

The character base class registers it's states on BeginPlay. It will ask for a class that inherits from CharacterState and a uint8 StateID. The state class contains the gameplay logic for that particular state.


/* Set default states */
PrimaryStateMachine->SetDefaultStateId((uint8)EBbPrimaryCharacterStateId::PCSI_Idle);
SecondaryStateMachine->SetDefaultStateId((uint8)EBbSecondaryCharacterStateId::SCSI_Idle);

/* Register states */
PrimaryStateMachine->RegisterState(UBbCharacterState::StaticClass(), (uint8)EBbPrimaryCharacterStateId::PCSI_None);
PrimaryStateMachine->RegisterState(UBbIdleState::StaticClass(), (uint8)EBbPrimaryCharacterStateId::PCSI_Idle);
PrimaryStateMachine->RegisterState(UBbRunState::StaticClass(), (uint8)EBbPrimaryCharacterStateId::PCSI_Run);
PrimaryStateMachine->RegisterState(UBbJumpState::StaticClass(), (uint8)EBbPrimaryCharacterStateId::PCSI_Jump);

SecondaryStateMachine->RegisterState(UBbCharacterState::StaticClass(), (uint8)EBbSecondaryCharacterStateId::SCSI_None);
SecondaryStateMachine->RegisterState(UBbSecondaryIdleState::StaticClass(), (uint8)EBbSecondaryCharacterStateId::SCSI_Idle);
SecondaryStateMachine->RegisterState(UBbSecondaryShootState::StaticClass(), (uint8)EBbSecondaryCharacterStateId::SCSI_Shoot);
SecondaryStateMachine->RegisterState(UBbSecondarySwingState::StaticClass(), (uint8)EBbSecondaryCharacterStateId::SCSI_Swing);
SecondaryStateMachine->RegisterState(UBbSecondaryBuildState::StaticClass(), (uint8)EBbSecondaryCharacterStateId::SCSI_Build);

The state machine:


class BITBLITZ_API UBbCharacterStateMachine : public UActorComponent
{
	GENERATED_BODY()

public:	
	UBbCharacterStateMachine();

protected:
	virtual void BeginPlay() override;
	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
	virtual bool IsSupportedForNetworking() const override;

public:	
	virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;

	UFUNCTION(BlueprintPure, Category = "Character|StateMachine")
	uint8 GetActiveStateId() const;

	UFUNCTION(BlueprintPure, Category = "Character|StateMachine")
	UBbCharacterState* GetActiveState() const;

	/**
	*	Locks the state from transitioning
	*	@param StateLocker The object that wants to lock the state transitions (used to identify what state locker to remove)
	*/
	UFUNCTION(BlueprintCallable, Category = "Character|StateMachine")
	void LockState(UObject* StateLocker);

	/**
	*	Removes the given StateUnlocker from the state locker array
	*	State can transition when the StateLockers array is empty
	*	@param StateUnlocker The object that wants to stop locking the state transitions
	*/
	UFUNCTION(BlueprintCallable, Category = "Character|StateMachine")
	bool UnlockState(UObject* StateUnlocker);

	/**
	*	Determines what state should be active initially 
	*	And which state should be activated when a state is deactivates
	*	@param StateId 
	*/
	void SetDefaultStateId(const uint8 StateId);

private:
	UFUNCTION()
	void SetActiveStateId(const uint8 StateId);

	UFUNCTION(Server, Reliable, WithValidation)
	void NotifySetActiveStateId_Server(const uint8 StateId);
	void NotifySetActiveStateId_Server_Implementation(const uint8 StateId);
	bool NotifySetActiveStateId_Server_Validate(const uint8 StateId);

public:
	/**
	*	Checks if the activate state can transition to the given state
	*	If so, the state will be activated
	*	@param StateId the id of the state to activate
	*	@param bIgnoreTransitionRules force activate the state regardless of transition rules (does not override state lockers)
	*/
	EBbActivateStateResult TryActivateState(const uint8 StateId, bool bIgnoreTransitionRules = false);

	/**
	*	Deactivates the given state if it is the activate state and activated the default state (0)
	*	@param StateId The id of the state to deactivate
	*/
	void TryDeactivateState(const uint8 StateId);

	/**
	*	Registered the state and activates it if it is the default state
	*	@param StateClass the class of the state (do not use the base class)
	*	@param StateId the id this state should have
	*/
	void RegisterState(TSubclassOf<UBbCharacterState> StateClass, const uint8 StateId);
	
private:
	bool CanTransitionTo(const uint8 StateId);

private:
	UPROPERTY(Replicated)
	uint8 ActiveStateId;

	UPROPERTY()
	UBbCharacterState* ActiveState;

	UPROPERTY()
	TArray<UObject*> StateLockers;

	UPROPERTY()
	ABbCharacter* Character;

	UPROPERTY()
	UBbDebugDatabase* DebugDatabase;

	UPROPERTY()
	UBbCharacterDatabase* CharacterDatabase;

	UPROPERTY()
	TMap<uint8, UBbCharacterState*> RegisteredStates;

	EBbStateMachineType StateMachineType;
	uint8 DefaultStateId;
};

The CanTransitionTo method will check if the current state is eligble to transition to the given state. To make this a bit easier to visualize I have created a data asset where I can configure the State Transition Rules. To give an example: the character should not be allowed to go into the jump state while crouching or jumping.



The active state ID's are replicated over the network. This is nice because I only have to send one 8-bit integer over the network and all the logic that are contained in states are functional over the network, keeping bandwidth usage to a minimum.

The state machines are also very helpful when it comes to creating an animation blueprint.


Combat & Equipment

My game features ranged and melee combat. First I created a base class for anything that can be attached to the character skeleton called an Attachable. This contains data about the enum slot it should be attached to and a custom transform relative to the socket. The character base has a function that takes an attachable and handles the rest. Now the inheritance tree splits of into Wieldable and ArmorPiece. The wieldable will contain additional variables (e.g. enum HandType (one-handed or two-handed)). Next I created a base class for every weapon called Weapon which inherits from wieldable which splits of again into MeleeWeapon and RangedWeapon.

The equipment of the character is managed by the EquipmentManager component which contains functionality for equiping and dropping weapons and armor. The methods are RPC's and work over the network. When a weapon is equipped, the weapon that was previously equipped is converted into a pickup and dropped in the world to be picked up again.


void UBbEquipmentManager::EquipWieldable(EBbWieldMode Slot, ABbWieldable* WieldableToEquip)
{
	if (WieldableToEquip == nullptr)
	{
		printWarning("Failed to equip: weapon argument is nullptr");
		return;
	}

	if (EquippedWieldables[Slot]) 
	{
		DropWieldable(Slot);
	}

	bool bHasChanged = EquippedWieldables[Slot] != WieldableToEquip;

	EquippedWieldables[Slot] = WieldableToEquip;
	EquippedWieldables[Slot]->ParentCharacter = Character;
	Character->AttachToSlot(EquippedWieldables[Slot], EBbSocketAttachmentSlot::SAS_RightHand);
	Character->GetSecondaryStateMachine()->TryActivateState((uint8)EBbSecondaryCharacterStateId::SCSI_Idle);
	
	if (bHasChanged)
	{
		OnWieldableChanged.Broadcast(EquippedWieldables[Slot]);
	}
}

The blueprint of a random ranged weapon and a melee weapon look as follows:



The design allows for easy creation of new weapons, only the variables need to be tweaked, no additional code is usually needed.

All melee weapons have a box component which size can be tweaked that I use in my box trace to determine if we hit something. I cache the transform of the previous frame and trace it to the transform of the current frame.


FTransform Transform = HitBox->GetComponentTransform();
FVector StartTranslation = CachedBoxTransform.GetTranslation();
FVector EndTranslation = Transform.GetTranslation();

FCollisionObjectQueryParams CollisionObjectQueryParams;
CollisionObjectQueryParams.ObjectTypesToQuery = FCollisionObjectQueryParams::AllObjects;

FCollisionShape Shape = FCollisionShape::MakeBox(HitBox->GetScaledBoxExtent());

FCollisionQueryParams CollisionQueryPararms;
CollisionQueryPararms.bTraceComplex = false;
CollisionQueryPararms.TraceTag = TraceTag;

if (ParentCharacter) 
{
    CollisionQueryPararms.AddIgnoredActor(ParentCharacter);
}

TArray<FHitResult> OutHits;

bool bHitDetected = GetWorld()->SweepMultiByObjectType(
    OutHits, StartTranslation, EndTranslation, CachedBoxTransform.GetRotation(), 
    CollisionObjectQueryParams, Shape, CollisionQueryPararms);

if (bHitDetected)
{
    for (auto Hit : OutHits)
    {
        AActor* HitActor = Hit.GetActor();
        if (HitActor)
        {
            if (HitActor->bCanBeDamaged && !AlreadyHitActors.Contains(HitActor))
            {
                UGameplayStatics::ApplyPointDamage(
                    HitActor, Damage, -Hit.ImpactNormal, Hit, nullptr, this, 
                    UBbCoreUtils::GetDatabase<UBbBlueprintClassDatabase>(this)->PointHitDamageTypeClass);

                if (HitActor->IsA<ACharacter>())
                {
                    ACharacter* HitCharacter = Cast<ACharacter>(HitActor);
                    FVector Diff;
                    if (ParentCharacter) 
                    {
                        Diff = (HitCharacter->GetActorLocation() - ParentCharacter->GetActorLocation());
                    }
                    else
                    {
                        Diff = (HitCharacter->GetActorLocation() - GetActorLocation());
                    }

                    FVector KnockbackForce;

                    if (Diff.Y > 0.0f)
                    {
                        KnockbackForce = FVector(0.0f, KnockbackForwardForce, KnockbackUpForce);
                    }
                    else
                    {
                        KnockbackForce = FVector(0.0f, -KnockbackForwardForce, KnockbackUpForce);
                    }

                    HitCharacter->LaunchCharacter(KnockbackForce, true, true);
                }

                AlreadyHitActors.AddUnique(HitActor);
            }

        }
    }
}

if (bEnableTraceDraws) 
{
    GetWorld()->DebugDrawTraceTag = TraceTag;
}
}

CachedBoxTransform = HitBox->GetComponentTransform();
bTraceWasEnabled = bTraceEnabled;

The box will be enabled and disabled during the swing montage at an appropriate moment.



Building Mechanic & Level Maker

My game features a Building mechanic that allows the player to change the environment by building (destructable) objects. The build logic is located in a secondary state.


ABbPlayerController* PC = Cast<ABbPlayerController>(Character->GetController());
if (PC == nullptr)
{
    return;
}

/* Get mouse location in world space */
FVector MouseLocation = UBbGameplayUtils::GetMouseWorldLocation(Character, PC);

int32 LocY = MouseLocation.Y;
int32 LocZ = MouseLocation.Z;

int32 CharY = Character->GetActorLocation().Y;
int32 CharZ = Character->GetActorLocation().Z;

/* Clamp build locations to build range */
LocY = FMath::Clamp(LocY, CharY - BuildTool->MaxBuildRange, CharY + BuildTool->MaxBuildRange);
LocZ = FMath::Clamp(LocZ, CharZ - BuildTool->MaxBuildRange, CharZ + BuildTool->MaxBuildRange);

FVector GridLoc = UBbBuildUtils::GetClosestGridLocation(FVector(0.0f, LocY, LocZ));

/* Valid location check */
if (!IsValidBuildLocation(GridLoc))
{
    GhostBlock->SetPlacementValid(false);
}
else 
{
    bool bIsOccupied = UBbBuildUtils::IsGridCellOccupied(Character, GridLoc, FCollisionObjectQueryParams::AllObjects);
    GhostBlock->SetPlacementValid(!bIsOccupied);	
}

GhostBlock->SetActorLocation(GridLoc);

BuildTool->BuildLocation = GridLoc;
BuildTool->bCanBuild = GhostBlock->bIsValidBuildLocation;

if (bIsBuildKeyDown) 
{
    Build();
}


The building mechanic is grid-based. I created a static function library for the functions that determine valid build locations, what's occupying a grid node, etc. This way I could easily unite the building mechanic for the player in-game and the level maker.

The level maker is a seperate level that can be accessed by the player via the main menu. This allows players to create and share their own levels. The level are serialized to a JSON. This is still a work in progress so the image below shows a very early version.



Level loading by deserializing the JSON file:


void UBbLevelMaker::LoadLevel(FString JsonString, FString LevelFile)
{
	UnloadCurrentLevel();

	TSharedPtr<FJsonObject> OutJsonObject;
	TSharedRef<TJsonReader<TCHAR>> JsonReader = TJsonReaderFactory<TCHAR>::Create(JsonString);

	if (!FJsonSerializer::Deserialize<TCHAR>(JsonReader, OutJsonObject))
	{
		LM_FATAL_LEVEL_LOAD_ERROR();
		return;
	}

	TMap<FString, TSharedPtr<FJsonValue>> ValueMap = OutJsonObject->Values;
	for (auto JsonKvp : ValueMap)
	{
		if (JsonKvp.Key == LM_OBJECT_FIELD_OBJECTS)
		{
			TSharedPtr<FJsonObject> LevelObjectsSection = JsonKvp.Value->AsObject();
			for (auto LevelObjectKvp : LevelObjectsSection->Values)
			{
				TSharedPtr<FJsonObject> LevelObjectJsonObj = LevelObjectKvp.Value->AsObject();
				FString ClassString = LevelObjectJsonObj->GetStringField(LM_STRING_FIELD_CLASS);
				FString TransformString = LevelObjectJsonObj->GetStringField(LM_STRING_FIELD_TRANSFORM);
				EBbLevelObjectType ObjectType = (EBbLevelObjectType)((uint8)LevelObjectJsonObj->GetIntegerField(LM_INT_FIELD_OBJECT_TYPE));

				FTransform SpawnTransform = FTransform::Identity;
				if (!SpawnTransform.InitFromString(TransformString))
				{
					LM_FATAL_LEVEL_LOAD_ERROR();
					break;
				}
				AActor* SpawnedActor = UBbLevelMakerUtils::SpawnLevelObjectFromString(this, ClassString, SpawnTransform, true);
				if (!SpawnedActor)
				{
					LM_FATAL_LEVEL_LOAD_ERROR();
					break;
				}
				Level.LevelContent[ObjectType].Array.Add(SpawnedActor);
			}
		}
	}

	FString FileName = FPaths::GetBaseFilename(LevelFile);
	LoadedLevelName = FileName;
	LoadedLevelFilePath = LevelFile;
}