Pablo Villanueva

Gameplay Programmer · C++ · UE5

I'm a C++ Gameplay Programmer passionate about building modular and scalable gameplay systems in Unreal Engine 5. I love treating my projects as a laboratory — always experimenting, iterating, and pushing my architecture further.


Skills:

C++, Unreal Engine 5, Git.


Projects:

ECS Project is a non production ready implementation of a Entity Component System in C++. I built it to understand how ECS works internally, and what is below a game engine such as Unreal Engine 5. The goal of this project was to understand ECS pattern and the potential of it.

The hat is a component!
The hat is a component!

One of the things that caught my eye is the difference between OOP and ECS. I was used to thinking in classes with data and behavior bundled together, and of course using inheritance. ECS flips that: entities are just IDs, components are pure data, and systems are the logic. I heared about "Composition over inheritance" but experiencing it in practice rather than just reading about it, hits different.

OOP Example
OOP Example
ECS Example
ECS Example
GitHub →

DynArray is a small custom implementation of a dynamic array in C++. I built it as a learning project to better understand how dynamic containers work internally, especially memory allocation, capacity growth, element access,move semantics, and iterator support. I chose the name DynArray instead of Vector because the term "vector" can be confusing, especially when coming from math or physics, where a vector usually means something different.

Example Usage — Custom Dynamic Array
DynArray<int> numbers;

numbers.PushBack(10);
numbers.PushBack(20);
numbers.PushBack(30);

numbers[0] = 100;

for (const int value : numbers) {
    std::cout << value << " ";
}

In this project I learned the importance of move semantics, which is how to transfer ownership of resources between objects. Next we have Copy Constructor and Move Constructor which are used to create new objects from existing ones. Copy Constructor creates a new object based on an existing one, while Move Constructor transfers ownership of resources from one object to another. To put it another way, in the first one you are literally copying the data from one reference, while in the second one you are 'moving' the data from one object(passing a rvalue reference)to another.

Copy Constructor
// Copy Constructor
DynArray(const DynArray& other) :
Data(nullptr), CurrentSize(0), CurrentCapacity(0)
{
    if (other.CurrentSize > 0) {
      CurrentCapacity = other.CurrentSize;
      Data = new T[CurrentCapacity];

      for (SizeType i = 0; i < CurrentCapacity; ++i) {
        Data[i] = other.Data[i];
      }

            CurrentSize = other.CurrentSize;
    }
  }

Move Constructor
// Move constructor transfers ownership of resources from another DynArray instance.
DynArray(DynArray&& other) noexcept :
    Data(other.Data), CurrentSize(other.CurrentSize), CurrentCapacity(other.CurrentCapacity)
    {
        other.Data = nullptr;
        other.CurrentSize = 0;
        other.CurrentCapacity = 0;
    }

The difference between copy and move is how they handle an object's resources. Copying creates a duplicate, while moving transfers ownership of existing data to a new object.

GitHub →

"Allocators" project is a simple implementation of common allocators used in game development. I created a simple implementation of Linear Allocator/Bump Allocator for learning propose. I plan to expand this project to include more allocators, such as Pool Allocator and Stack Allocator.

Bump/Linear Allocator

Allocate and Reset functions for Linear Allocator
void* Allocate(size_t Size) {
        if(Offset + Size > Capacity)
            return nullptr;

        void* ptr = Buffer + Offset;

        Offset += Size;

        return ptr;
    }

    void Reset() {
        Offset = 0;
    }

Allocates Size bytes from the internal buffer. Returns a pointer to the allocated memory, or nullptr if there is not enough remaining capacity. The returned memory is valid until Reset() is called or the allocator is destroyed. Reset() resets the allocator to its initial state, allowing it to allocate memory again. It just move the offset to the beginning of the buffer.

Example of 'Bump Allocator' in action with memory view
Example of 'Bump Allocator' in action with memory view
GitHub →

I created a Dialog Component that is simple and easy to use for designers, but also powerful and flexible.
I used C++ for dialog logic and I exposed some methods to designers using Blueprints.
Designers can modify the dialogues through a Data Asset and play them using Play Dialog By Name function.
I'll try to explain this system brifely...

The main function: Play Dialog By Name function

          void UDialogComponent::PlayDialogByName(EDialogType DialogType,FName DialogName)
{
	if (DialogType == EDialogType::Default)
	{
		if (bCooldown)
			return;

		StartCooldown();
	}
	
	ShowDialog(DialogType,DialogName);
}

The function takes DialogType and DialogName as parameters.
The DialogType is used to determine whether the dialog is a main dialog or a normal dialog.

Show Dialog
void UDialogComponent::ShowDialog(EDialogType DialogType, const FName& DialogName)
{
	if (IsMainDialog(DialogType))
		ShowMainDialog(DialogName);
	else
		ShowNormalDialog(DialogName);
}

The function calls either ShowMainDialog or ShowNormalDialog depending on the DialogType.

Main Dialog
void UDialogComponent::ShowMainDialog(FName MainDialogName)
{
	if (!DialoguesData || MainDialogName.IsNone())
		return;
	
	bDialogPlaying = true;
  DialogWidget->AddDialogEntry(DialoguesData->GetDialog(MainDialogName),EDialogType::Main);
}
          

The function adds the main dialog to the dialog widget.
The difference between Main and Normal dialog is:
Normal dialog can be played multiple times but main dialog is played only ONCE for that reason is more important than normal dialog, if a normal dialog is playing and we try to play a main dialog it will display inmediately and stop the normal dialog.

Normal Dialog
void UDialogComponent::ShowNormalDialog(FName DialogName)
{
	if (!DialoguesData || DialogName.IsNone())
		return;

	if (IsDialogPlaying())
		return;
	
	DialogWidget->AddDialogEntry(DialoguesData->GetDialog(DialogName), EDialogType::Default);
	bDialogPlaying = true;
}
          

The function adds the normal dialog to the dialog widget. Also is important to know that normal dialog has a cooldown, so if a normal dialog is playing and we try to play another normal dialog it wont display. The cooldown prevents spamming normal dialogues.

Through His Eyes Demo Trailer

You can see the dialogues in action in the trailer video :D

Steam →

I created a Workbench Combination System that allows the player to combine collected fragments and unlock new collectible items.
The core logic is written in C++, while feedback animations, presentation events, and UI moments are exposed to Blueprints so they can be easily adjusted in the editor.

The system checks if the player has enough fragments, selects a valid item category, spends the required fragments, and then plays a short reward presentation sequence.

Checking if a combination is available

bool AWorkbenchActor::HasCombinationAvailable()
{
	RefreshFragmentCounts();
	return GetValidCombinationTypes().Num() > 0;
}

This function refreshes the current fragment amount from the game instance and checks if there is at least one valid item type that can be crafted.
If a valid combination exists, the workbench changes to a ready state and shows feedback to the player.

Getting valid combination types

TArray<EItemType> AWorkbenchActor::GetValidCombinationTypes() const
{
	TArray<EItemType> ValidTypes;

	for (const auto& Pair : FragmentCounts)
	{
		const EItemType ItemType = Pair.Key;
		const int Count = Pair.Value;

		if (Count >= FragmentNeeded && HasRemainingItemsOfType(ItemType))
		{
			ValidTypes.Add(ItemType);
		}
	}

	return ValidTypes;
}

The system loops through the player's fragment inventory and finds which item categories can be used.
A category is valid only if the player has enough fragments and there are still uncollected rewards available for that type.

Trying to combine fragments

void AWorkbenchActor::TryToCombineFragments()
{
	WorkbenchState = EWorkbenchState::Combining;
	
	RefreshFragmentCounts();

	EItemType SelectedItemType;
	if (!PickRandomCombinationType(SelectedItemType))
	{
		CheckForCombinations();
		ShowCollectionCategoryCompleteFeedback();
		UnlockPlayer();
		return;
	}

	if (!ChesterGameInstance || !ChesterGameInstance->SpendFragments(SelectedItemType, FragmentNeeded))
	{
		CheckForCombinations();
		UnlockPlayer();
		return;
	}

	CommitCombination(SelectedItemType);
}

This is the main crafting flow.
The workbench selects a valid item type, spends the required fragments, and then commits the reward. If no item can be created, the system safely restores the player state and updates the workbench feedback.

Creating the reward

void AWorkbenchActor::CommitCombination(EItemType ItemType)
{
	PendingRewardName = ChesterGameInstance->GetRandomRemainingItem(ItemType);
	
	if (PendingRewardName.IsNone())
	{
		CheckForCombinations();
		ShowCollectionCategoryCompleteFeedback();
		UnlockPlayer();
		return;
	}
	
	ChesterGameInstance->CollectItem(PendingRewardName, ItemType);
	SetupRewardUI();
	PlayCombinationPresentation();
}

After a valid item type is selected, the system chooses a random remaining reward from that category.
The reward is registered in the player's collection, the reward UI is prepared, and a Blueprint presentation event is triggered so the animation can be handled visually.

World space widget looking at the camera

void AWorkbenchActor::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	if (!CombiationAvailableWidget)
	{
		return;
	}

	const APlayerCameraManager* CameraManager = UGameplayStatics::GetPlayerCameraManager(GetWorld(), 0);
	if (!CameraManager)
	{
		return;
	}

	const FVector WidgetLocation = CombiationAvailableWidget->GetComponentLocation();
	const FVector CameraLocation = CameraManager->GetCameraLocation();

	const FRotator LookAtRotation = UKismetMathLibrary::FindLookAtRotation(WidgetLocation, CameraLocation);
	CombiationAvailableWidget->SetWorldRotation(LookAtRotation);
}

The workbench also uses a World Space Widget to show feedback in the level.
Since the widget exists in 3D space, it updates its rotation every frame to face the player camera, making it readable from the gameplay view.

Blueprint presentation event

UFUNCTION(BlueprintImplementableEvent)
void PlayCombinationPresentation();

I exposed the reward presentation as a BlueprintImplementableEvent.
This lets me keep the gameplay rules in C++, while the visual animation and timing can be created directly in Blueprint.

Steam →

Games:

Through His Eyes Demo Trailer

Through His Eyes is my latest game, a horror game with lot of puzzles, character inmersion and simple but robust dialog system. I created systems that are simple and easy to use for designers, but also powerful and flexible. I used C++ for game logic and Blueprints for effects, animation, etc.

Through His Eyes capsule
Through His Eyes capsule
Through His Eyes: TV Screenshot
Through His Eyes: TV Screenshot
Steam →

Chester The Chest Release Trailer

Chester The Chest is my first comercial game as solo game dev,until this game I just made a few games in game jams. I used to build everything in Blueprints of course was a bad idea, nowadays game logic is in C++

Chester The Chest capsule
Chester The Chest capsule
Chester The Chest: Snow Level
Chester The Chest: Snow Level
Steam →