David Liu

X-iled

About the Game

X-iled is a deckbuilder, action rogue-lite developed in Unreal Engine 4 using C++. You, the player, take the role of a cursed spirit that has been exiled into the deepest, darkest depths of an underground dungeon. Having been trapped in the shadows for centuries, take control over the monsters that imprisoned you for your revenge. Every monster you destroy adds to your arsenal of vessels for your next escape attempt. New powers can be discovered as well, dropped by the monsters that are capable of using them. Add these powers to your deck and harness them in real-time combat with a card-cycling deck system. Defeat more enemies to get access to stronger forms, and unleash devastation for those in your path. Forge your own path through the dungeon, and gain the freedom that was taken away from you.

Developed from May 2020 to May 2021.

Play the game here!

Role in Development

  • Manager: David Knolls
  • Designer: David Knolls, David Liu
  • Programmers: David Knolls, David Liu, Nathan Glick

X-iled is my Capstone project that is still undergoing development. Throughout the year-long process, there have been lots of prototyping, iterating, playtesting, and debugging. Our rough idea prototypes at the beginning of the project were variations of a turn-based card game. We came up with a few interesting ideas and collaborated on finding a way to bring them all together into a solid, clear game.

We iterated and playtested our prototypes throughout the rest of summer, and got down a unique system for a card game. However, once fall started to come around, it dawned on us that most of the mechanics we wanted to make would've worked better visually inside the context of a 3D world, rather than 2D static images of cards. That lead us to our first rework of the project, which was to shift the game into a 3D RPG adventure. However, we had been warned about the scope of RPGs as they tend to be rather massive in scale, so after some consultation we had switched the project idea again into a 3D rogue-lite.

This is where we began to dive into using Unreal Engine rather than tabletop prototypes. We started with setting up the base movement system, combat system, and a very basic enemy AI. I took on the role of a systems and gameplay programmer here, where I was in charge of getting the combat system structured and working, alongside help from David Knolls. We went through stand-up meetings each week to design and test exciting features or attacks, piecing together the game we planned for bit by bit.

However, after evaluating the needs of the project with the remaining time and getting critique from a few industry professionals, we changed to a 2.5D view to make it easier to handle logic on a 2D plane, but still keep the 3D visual feel. We have worked tirelessly on iterating the features and mechanics, bringing the core gameplay to the best it can be. After reaching the milestones for our base gameplay, we sent a build out for playtesters.

The feedback we got from the playtests was a huge help with identifying issues in the game, and there were many suggestions that the team didn't consider before, but brought a lot to making the game a more complete experience. We took each playtester's feedback with lots of consideration, and did our best to assess all the issues and additions, but we did not have enough time to fit every feature into our scope of the project. Moving forward, we continued developing more systems with menus hooking them together, and began to add in art assets to really make the game come together.

There are many areas that can be improved, but we were happy with the end result. The game fit the style and feel we wanted, the systems work well together, and we made a successfully large project! Most of the takeaways though is from our development process, where we had lots of struggles and lessons we learned. The further we went into development, the more we learned to collaborate and work together despite having differing views. We learned to be open and adapt our process and ideas when it was necessary, shifting away from our initial visions that were too out of scope for the project. We trusted in each other to take responsibility in our own sections of the project, and be open to help with other areas when requested. We took a ragged, disoriented start and transformed it into a structured, defined product. And now, I will continue going forward with better development in future projects, having all these experiences of overcoming obstacles, and always improving processes and practices.

You can see more about our process on our game development blog!

C++ Examples

DNDCapstoneCharacter.csMyPlayerController.csMyGameInstance.csAttack_Beam.csSpell_Beam.csICombat.cs
// Copyright Epic Games, Inc. All Rights Reserved.

#include "DNDCapstoneCharacter.h"
#include "../All.h"
#include "HeadMountedDisplayFunctionLibrary.h"
#include "Camera/CameraComponent.h"
#include "Components/CapsuleComponent.h"
#include "Components/InputComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "GameFramework/Controller.h"
#include "GameFramework/SpringArmComponent.h"
#include "Kismet/GameplayStatics.h"
#include "PaperFlipbookComponent.h"

ADNDCapstoneCharacter::ADNDCapstoneCharacter()
{
	
	// Set size for collision capsule
	GetCapsuleComponent()->InitCapsuleSize(42.f, 96.0f);
	// set our turn rates for input
	BaseTurnRate = 45.f;
	BaseLookUpRate = 45.f;

	// Don't rotate when the controller rotates. Let that just affect the camera.
	bUseControllerRotationPitch = false;
	bUseControllerRotationYaw = false;
	bUseControllerRotationRoll = false;

	// Configure character movement
	GetCharacterMovement()->bOrientRotationToMovement = false; // Character moves in the direction of input...	
	GetCharacterMovement()->RotationRate = FRotator(0.0f, 540.0f, 0.0f); // ...at this rotation rate
	GetCharacterMovement()->JumpZVelocity = 600.f;
	GetCharacterMovement()->AirControl = 0.2f;

	// Create a camera boom (pulls in towards the player if there is a collision)
	CameraBoom = CreateDefaultSubobject(TEXT("CameraBoom"));
	CameraBoom->SetupAttachment(RootComponent);
	CameraBoom->TargetArmLength = 300.0f; // The camera follows at this distance behind the character	
	CameraBoom->bUsePawnControlRotation = true; // Rotate the arm based on the controller

	// Create a follow camera
	FollowCamera = CreateDefaultSubobject(TEXT("FollowCamera"));
	FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName); // Attach the camera to the end of the boom and let the boom adjust to match the controller orientation
	FollowCamera->bUsePawnControlRotation = false; // Camera does not rotate relative to arm

	previewSpriteDir = CreateDefaultSubobject(TEXT("DirSprite"));
	previewSpriteDir->SetCollisionEnabled(ECollisionEnabled::Type::NoCollision);
	previewSpriteDir->SetupAttachment(GetMesh());
	previewSpriteAOE = CreateDefaultSubobject(TEXT("AOESprite"));
	previewSpriteAOE->SetCollisionEnabled(ECollisionEnabled::Type::NoCollision);
	previewSpriteAOE->SetupAttachment(GetMesh());

	// Note: The skeletal mesh and anim blueprint references on the Mesh component (inherited from Character) 
	// are set in the derived blueprint asset named MyCharacter (to avoid direct content references in C++)

	// Initialize input vector for movement
	directionalInput = FVector(0);

	// Post processing settings for wall shader
	GetMesh()->SetRenderCustomDepth(true);
	GetMesh()->CustomDepthStencilValue = 2;
	previewSpriteDir->SetRenderCustomDepth(true);
	previewSpriteDir->CustomDepthStencilValue = 4;
	previewSpriteAOE->SetRenderCustomDepth(true);
	previewSpriteAOE->CustomDepthStencilValue = 4;
}

//////////////////////////////////////////////////////////////////////////
// Input
void ADNDCapstoneCharacter::SetStats() 
{
	SetHealth(100);
	SetMaxHealth(100);
}
void ADNDCapstoneCharacter::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent)
{

	Super::SetupPlayerInputComponent(PlayerInputComponent);

	inputComponent = PlayerInputComponent;

	// Set up gameplay key bindings
	check(PlayerInputComponent);

	PlayerInputComponent->BindAction("KBInteract", IE_Released, this, &ADNDCapstoneCharacter::KB_Interact);
	PlayerInputComponent->BindAxis("KBMoveForward", this, &ADNDCapstoneCharacter::KB_MoveForward);
	PlayerInputComponent->BindAxis("KBMoveRight", this, &ADNDCapstoneCharacter::KB_MoveRight);
	PlayerInputComponent->BindAction("KBDash", IE_Pressed, this, &ADNDCapstoneCharacter::KB_Dash);

	PlayerInputComponent->BindAxis("ControllerRotateX", this, &ADNDCapstoneCharacter::ControllerRotateX);
	PlayerInputComponent->BindAxis("ControllerRotateY", this, &ADNDCapstoneCharacter::ControllerRotateY);
	PlayerInputComponent->BindAction("ControllerInteract", IE_Released, this, &ADNDCapstoneCharacter::C_Interact);
	PlayerInputComponent->BindAxis("ControllerMoveForward", this, &ADNDCapstoneCharacter::C_MoveForward);
	PlayerInputComponent->BindAxis("ControllerMoveRight", this, &ADNDCapstoneCharacter::C_MoveRight);
	PlayerInputComponent->BindAction("ControllerDash", IE_Pressed, this, &ADNDCapstoneCharacter::C_Dash);
}

// Called when the game starts or when spawned
void ADNDCapstoneCharacter::BeginPlay()
{
	Super::BeginPlay();
	characterC = Cast(UGameplayStatics::GetPlayerController(this, 0));
	if (Validate(characterC))
	{
		uiManager = characterC->uiManager;
		soundManager = instance->GetSubsystem();
	}
	if (Validate(uiManager)) 
	{
		if (uiManager->menuList.Contains(Menu::CombatOverlay))
		{
			combatOverlay = Cast(uiManager->menuList[Menu::CombatOverlay]);
		}
	}
	
	OnDestroyed.AddDynamic(this, &ADNDCapstoneCharacter::OnDeath);
}

void ADNDCapstoneCharacter::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
	LockVerticality(lockVerticality);
	if (Validate(instance)) 
	{
		if (instance->usingKeyboard) 
		{
			KB_UpdateRotation();
		}
		else if(Validate(GetMesh()))
		{
			//Calculate rotation based on controllerRotation
			GetMesh()->SetWorldRotation(FRotator(0, controllerRotation.Rotation().Yaw, 0));
		}
	}

	//Check Z
	if (GetActorLocation().Z < -1000 && Validate(uiManager) && Validate(instance) && instance->currentLevel == Levels::DungeonActiveLevel) 
	{
		//Send to main menu
		uiManager->InitLoadLevel(Levels::MainMenuLevel);
	}
	if (dashCooldown > 0) 
	{
		dashCooldown -= DeltaTime;
	}
}
void ADNDCapstoneCharacter::KB_MoveForward(float Value)
{
	if (Value != 0 && Validate(characterC)) 
	{
		characterC->SetInputUpdate(true);
	}
	MoveForward(Value);
}
void ADNDCapstoneCharacter::KB_MoveRight(float Value)
{
	if (Value != 0 && Validate(characterC))
	{
		characterC->SetInputUpdate(true);
	}
	MoveRight(Value);
}
void ADNDCapstoneCharacter::KB_UpdateRotation()
{
	if (CanRotate() && Validate(characterC))
	{
		//FVector loc, dir;
		FHitResult hit;
		characterC->GetHitResultUnderCursor(ECC_GameTraceChannel1, false, hit);
		//characterC->DeprojectMousePositionToWorld(loc,dir);
		if (hit.bBlockingHit)
		{
			FVector newVec = GetActorLocation() - FVector(hit.ImpactPoint.X, hit.ImpactPoint.Y, 0);
			if (Validate(GetMesh()))
			{
				//GLog->Log("HITPOINT X: " + FString::SanitizeFloat(hit.ImpactPoint.X) + ", Y: " + FString::SanitizeFloat(hit.ImpactPoint.Y) + ", Z: " + FString::SanitizeFloat(hit.ImpactPoint.Z));
				//GLog->Log("X: " + FString::SanitizeFloat(newVec.X) + ", Y: " + FString::SanitizeFloat(newVec.Y) + ", Z: " + FString::SanitizeFloat(newVec.Z));
				//GLog->Log("Pitch: " + FString::SanitizeFloat(newVec.Rotation().Pitch) + ", YAW: " + FString::SanitizeFloat(newVec.Rotation().Yaw) + ", ROLL: " + FString::SanitizeFloat(newVec.Rotation().Pitch));
				GetMesh()->SetWorldRotation(FRotator(0, newVec.Rotation().Yaw + 90, 0));
			}
		}

	}
}
void ADNDCapstoneCharacter::KB_Dash()
{
	if (Validate(characterC))
	{
		characterC->SetInputUpdate(true);
	}
	Dash(true);
}
void ADNDCapstoneCharacter::KB_Interact()
{
	if (Validate(characterC))
	{
		characterC->SetInputUpdate(true);
	}
	Interact();
}
void ADNDCapstoneCharacter::C_MoveForward(float Value)
{
	if (Validate(instance) && !instance->usingKeyboard)
	{
		if (Value > 0)
		{
			Value = 1;
		}
		else if (Value < 0)
		{
			Value = -1;
		}
		MoveForward(Value);
	}
}
void ADNDCapstoneCharacter::C_MoveRight(float Value)
{
	if (Validate(instance) && !instance->usingKeyboard)
	{
		if (Value > 0)
		{
			Value = 1;
		}
		else if (Value < 0)
		{
			Value = -1;
		}
		MoveRight(Value);
	}
}
void ADNDCapstoneCharacter::C_Dash()
{
	if (Validate(characterC))
	{
		characterC->SetInputUpdate(false);
	}
	Dash(false);
}
void ADNDCapstoneCharacter::C_Interact()
{
	if (Validate(characterC))
	{
		characterC->SetInputUpdate(false);
	}
	Interact();
}
void ADNDCapstoneCharacter::ControllerRotateX(float Value)
{
	if (Value != 0 && Value != controllerRotation.X) 
	{
		controllerRotation.X = Value;
	}
}
void ADNDCapstoneCharacter::ControllerRotateY(float Value)
{
	if (Value != 0 && Value != controllerRotation.Y)
	{
		controllerRotation.Y = Value;
	}
}

void ADNDCapstoneCharacter::OnDeath(AActor* actor)
{
	if (Validate(uiManager)) 
	{
		uiManager->InitLoadLevel(Levels::DungeonActiveLevel);
	}
}

void ADNDCapstoneCharacter::MoveForward(float Value)
{
	if ((Validate(Controller)) && (Value != 0.0f) && CanMove() && levelMovementAllowed)
	{
		// find out which way is forward
		const FRotator Rotation = Controller->GetControlRotation();
		const FRotator YawRotation(0, Rotation.Yaw, 0);
		// get forward vector
		const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
		AddMovementInput(Direction, Value);

		// Add to directional input
		directionalInput += (Direction * Value).GetSafeNormal();
		directionalInput = directionalInput.GetSafeNormal();
	}
}

void ADNDCapstoneCharacter::MoveRight(float Value)
{	
	if ( Validate(Controller) && (Value != 0.0f) && CanMove() && levelMovementAllowed)
	{
		// find out which way is right
		const FRotator Rotation = Controller->GetControlRotation();
		const FRotator YawRotation(0, Rotation.Yaw, 0);
	
		// get right vector 
		const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
		
		// add movement in that direction
		AddMovementInput(Direction, Value);

		// Add to directional input
		directionalInput += (Direction * Value).GetSafeNormal();
		directionalInput = directionalInput.GetSafeNormal();
	}
}

void ADNDCapstoneCharacter::Interact()
{

	if (Validate(levelTriggerA)) 
	{
		// check level trigger
		float dist = (levelTriggerA->GetActorLocation() - this->GetActorLocation()).Size();

		GLog->Log(FString("Pressed Interact A! ") + FString::SanitizeFloat(dist));

		if (dist <= 700 && Validate(procManager))
		{
			//procManager->LoadRoom(0);
			//UGameplayStatics::OpenLevel(this, FName("Demo2"), false);
			//procManager->Reset();
			if (Validate(uiManager)) 
			{
				uiManager->InitLoadLevel(Levels::DungeonActiveLevel);
			}
			return;
		}
	}
	return;
	if (Validate(levelTriggerB))
	{
		// check level trigger
		float dist = (levelTriggerB->GetActorLocation() - this->GetActorLocation()).Size();

		GLog->Log(FString("Pressed Interact B! ") + FString::SanitizeFloat(dist));

		if (dist <= 700 && Validate(procManager))
		{
			procManager->LoadRoom(1);
			return;
		}
	}

	if (Validate(levelTriggerC))
	{
		// check level trigger
		float dist = (levelTriggerC->GetActorLocation() - this->GetActorLocation()).Size();

		GLog->Log(FString("Pressed Interact C! ") + FString::SanitizeFloat(dist));

		if (dist <= 700 && Validate(procManager))
		{
			procManager->LoadRoom(2);
			return;
		}
	}

	if (Validate(levelTriggerD))
	{
		// check level trigger
		float dist = (levelTriggerD->GetActorLocation() - this->GetActorLocation()).Size();

		GLog->Log(FString("Pressed Interact D! ") + FString::SanitizeFloat(dist));

		if (dist <= 700 && Validate(procManager))
		{
			procManager->LoadRoom(3);
			return;
		}
	}
}

void ADNDCapstoneCharacter::TakeSetDamage(int damage)
{
	ICombat::TakeSetDamage(damage);

	soundManager->PlayHurtSound();

	Super::TakeSetDamage(damage);
	if (!IsDead() && Validate(combatOverlay))
	{
		combatOverlay->UpdateHealthUI();
		combatOverlay->TriggerHitOverlay();
	}
}

void ADNDCapstoneCharacter::Heal(int heal)
{
	Super::Heal(heal);
	if (Validate(combatOverlay))
	{
		combatOverlay->UpdateHealthUI();
	}
}

void ADNDCapstoneCharacter::Dash(bool isKB) 
{
	if (dashCooldown <= 0 && Validate(instance) && Validate(instance->dash) && !Validate(currentDash) && CanMove() && levelMovementAllowed && levelActionsAllowed)
	{
		soundManager->PlayDashSound();

		currentDash = SpawnObjectHelper::SpawnAttack(this, instance->dash);
		// Set the direction to where the player is moving
		if ((isKB && inputComponent->GetAxisValue("KBMoveForward") == 0 && inputComponent->GetAxisValue("KBMoveRight") == 0) || // Check if no movement input from Keyboard
			(!isKB && inputComponent->GetAxisValue("ControllerMoveForward") == 0 && inputComponent->GetAxisValue("ControllerMoveRight") == 0)) // Check if no movement input from Controller
		{
			// Dash where the player is looking
			currentDash->direction = GetMesh()->GetRightVector();
		}
		else
		{
			// Dash in the direction of the current input
			currentDash->direction = directionalInput;
		}
		dashCooldown = DASH_DELAY;
	}
}

void ADNDCapstoneCharacter::LockVerticality(bool lock)
{
	if (lock)
	{
		FVector playerPos = GetActorTransform().GetLocation();
		SetActorLocation(FVector(playerPos.X, playerPos.Y, heightLockPos));
	}
}
void ADNDCapstoneCharacter::ClearForces() 
{
	if (GetMesh() && GetCharacterMovement()) 
	{
		GetMesh()->SetEnableGravity(false);
		GetMesh()->SetPhysicsLinearVelocity(FVector::ZeroVector);
		GetMesh()->SetPhysicsAngularVelocity(FVector::ZeroVector);
		GetCharacterMovement()->ClearAccumulatedForces();
	}
}