Farkle
Engine: Unreal Engine 5
Intended Platform: PC
Approximate Date of Development: October 2024
Genre: Dice Game
What is it?
The first time I made a digital version of the dice game, Farkle, was when I took IT-312 Software Development with C++. In this course I was tasked with creating a console application where the player could play Farkle. Recently, I’ve decided to take that console game, and turn it into an Unreal Engine 5 game.
This was a bit of a challenge for me, since this format is quite different from the “typical” game. By that I mean, the game isn’t played from the perspective of one character, like there would be in a first-person shooter, or a third-person shooter. The player doesn’t use WASD or a joystick to move around, and there’s actually no character movement in the game at all!
Description of Gameplay
The game of Farkle is a multiplayer game meant for 2-8 players. My adaptation allows for 1-4 players. The player rolls 6 dice to start, and from the results can pick several combinations to start earning points.
- 3-of-a-kind: 100 points x Dice face value (Except for 1, 3 1’s is 1,000 points)
- 3 pairs: 1,500 points
- Straight (1, 2, 3, 4, 5, 6): 3,000 points
- 1: 100 points
- 5: 50 points
The player can select as many or as few combinations as are available. Then, the player can choose to reroll the remaining dice to try and earn more points, but they run the risk of getting a FARKLE and losing all of their points. If the player doesn’t want to risk it, as long as there are dice remaining they can choose to end their turn and add the round score to their total score.
A FARKLE is when you roll any amount of dice and no scoring dice/combinations are found. Regardless of how many points you’ve collected in a turn, a FARKLE removes all of those points and begins the next players turn.
How to win: Collect 10,000 points! The first time a player reaches 10,000 points, the end game sequence begins. All players (excluding the player who reached 10,000 points) get one final turn to try and get the highest score. Once everyone has played their last turn, the player with the highest point value is crowned the winner!
Dice
Each dice has 6 arrow components attached to it to indicate which direction each face is facing. After being rolled, each dice waits until it’s no longer moving (or the velocity is 0), and then it checks which arrow component has the forward vector closest to (0.0, 0.0, 1.0). This arrow component corresponds with a dice face value. That value is then passed to the game manager.
Game Manager
Much of the functionality of this game happens behind the scenes, meaning there is not much happening on screen to indicate what’s going on. The most important actor/class in the game is the GameManager class.
Blueprint
The class is made up of a parent C++ class and its child blueprint class. The blueprint class handles much of the functionality required for communicating between other blueprint classes. This is done with Blueprint Interfaces.
C++ Class
The C++ class is about 500 lines of code, and much of it is the logic for determining if there is a valid scoring dice combination, and if so, calculating the score of those dice.
Reveal Code
#include "GameManager.h"
#include "DiceSpawner.h"
#include "PlayerCharacter.h"
#include "Kismet/GameplayStatics.h"
#include "Camera/CameraActor.h"
#include "FarkleCameraController.h"
#include "PlayerData.h"
#include "Dice.h"
// Sets default values
AGameManager::AGameManager()
{
// Set this actor to call Tick() every frame. You can turn this off to improve performance if you don't need it.
PrimaryActorTick.bCanEverTick = true;
RootComponent = CreateDefaultSubobject<USceneComponent>(TEXT("RootComponent"));
}
// Called when the game starts or when spawned
void AGameManager::BeginPlay()
{
Super::BeginPlay();
numDiceToRoll = 6;
areDiceSpawned = false;
playerController = Cast<APlayerController>(UGameplayStatics::GetPlayerController(GetWorld(), 0));
UGameplayStatics::GetAllActorsOfClass(GetWorld(), ACameraActor::StaticClass(), sceneCameras);
player = Cast<APlayerCharacter>(playerController->GetPawnOrSpectator());
// Make this -1 so that player 1 goes first
inOfCurrPlayer = -1;
turnActive = false;
turnsCompleted = 0;
}
void AGameManager::InitPlayers()
{
for (int i = 0; i < numPlayers; i++)
{
// Spawns a character
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("GM: Initing a player"));
APlayerData* newplayer = GetWorld()->SpawnActor<APlayerData>(APlayerData::StaticClass());
newplayer->SetTotalScore(0);
newplayer->SetNumDiceCanRoll(6);
playersList.Add(newplayer);
}
//UGameplayStatics::GetAllActorsOfClass(GetWorld(), APlayerData::StaticClass(), actorsList);
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("GM: Setting camera"));
SetPlayerCamera();
turnActive = true;
}
// Called every frame
void AGameManager::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
void AGameManager::SpawnDice(int num)
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("GM: Spawning Dice"));
float z = 0.f;
if (!areDiceSpawned)
{
diceBeingRolled.Empty();
// Prepare the number of dice that will be rolled
for (int i = 0; i < num; i++)
{
ADice* newDice = diceSpawner->SpawnOneDice(z);
z = z + 20.f;
if (newDice != nullptr)
{
diceBeingRolled.Add(newDice);
areDiceSpawned = true;
}
else
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("GM: Dice not spawned"));
}
}
}
else
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("GM: Dice already spawned"));
}
}
void AGameManager::RollDice()
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("GM: Starting to Roll Dice"));
if (diceBeingRolled.Num() > 0)
{
for (int i = 0; i < diceBeingRolled.Num(); i++)
{
if (diceBeingRolled[i] != nullptr)
{
diceBeingRolled[i]->RollDice();
GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &AGameManager::StoreRollResults, 8.f, false);
}
else
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("GM: Dice to roll is nullptr!"));
}
}
}
}
void AGameManager::StoreRollResults()
{
rollResults.Empty();
for (int i = 0; i < diceBeingRolled.Num(); i++)
{
// If valid result was returned
if (diceBeingRolled[i]->GetRollResult() != 99)
{
rollResults.Add(diceBeingRolled[i]->GetRollResult());
FString message = FString::Printf(TEXT("GM: Roll of %d found."), rollResults[i]);
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, message);
GetRollResult(rollResults[i]);
}
else
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("GM: Roll of 99 found."));
}
}
ShowDiceResults();
}
void AGameManager::ShowDiceResults_Implementation()
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("GM: ShowDiceResults_Implementation"));
}
void AGameManager::SetPlayerCamera()
{
/*
// Right now this just does default camera, need to implement a way to change to different cameras
AFarkleCamera* cam = cameraController->GetDefaultCamera();
// Sets it on the PLAYER CONTROLLER!
if (player)
{
player->SetCamera(cam);
}
*/
}
void AGameManager::SetNumPlayers(int numRealPlayers, int numCPUPlayers)
{
numPlayers = numRealPlayers;
numCPU = numCPUPlayers;
totalNumPlayers = numPlayers + numCPU;
FString message = FString::Printf(TEXT("GM: %d players."), totalNumPlayers);
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Purple, message);
DisplayPlayerScores(totalNumPlayers);
}
TArray<int> AGameManager::GetPlayerScores()
{
TArray<int> scores;
if (playersList.Num() > 0)
{
for (int i = 0; i < playersList.Num(); i++)
{
if (playersList[i] != nullptr)
{
scores.Add(playersList[i]->GetTotalScore());
}
}
}
else
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("GM: player list null 187"));
}
return scores;
}
/////////////////////////// Turn Management ////////////////////////////////////////////
void AGameManager::Turn()
{
if (playersList.Num() > 0)
{
// Get Data from the current player
APlayerData* currentPlayer = playersList[inOfCurrPlayer];
int index = currentPlayer->GetPlayerIndex();
int diceCanRoll = currentPlayer->GetNumDiceCanRoll();
int diceToRoll = currentPlayer->GetNumDiceToRoll();
int gameScore = currentPlayer->GetTotalScore();
turnScore = 0;
int scoreOfKeptDice = 0;
if (!currentPlayer->hasTakenLastTurn)
{
StartTurn(6);
}
else
{
ChooseAWinner();
}
// Default to 6 because the player will always roll 6 dice to start their turn
// Display totalScore
// Display roundScore
// Display player identifier
// Roll All 6 Dice!
// Get results of Dice roll
if (hasPlayerReachedGoal)
{
currentPlayer->hasTakenLastTurn = true;
}
}
else
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("GM: player list null 202"));
}
}
void AGameManager::NextTurn()
{
// Sets the index of current player to the next player
inOfCurrPlayer += 1;
if (inOfCurrPlayer >= numPlayers)
// If at the end of current player list then start from the beginning and increment the number of turns completed
{
inOfCurrPlayer = 0;
turnsCompleted += 1;
}
// Call Turn function
areDiceSpawned = false;
Turn();
}
int AGameManager::Reroll(int numDice, int selectionScore)
{
// numDice is the number of dice to be rerolled
// selectionScore is the score acquired from the previous roll
// We destroy all the dice in the blueprint
areDiceSpawned = false;
// Add previous roll's score to the current turn score
turnScore = turnScore + selectionScore;
SpawnDice(numDice);
return turnScore;
}
int AGameManager::EndTurn(int additionalScore)
{
turnScore = turnScore + additionalScore;
playersList[inOfCurrPlayer]->SetTotalScore(turnScore);
int total = playersList[inOfCurrPlayer]->GetTotalScore();
if(total >= 10000)
{
playersList[inOfCurrPlayer]->hasTakenLastTurn = true;
hasPlayerReachedGoal = true;
}
return turnScore;
}
/////////////////////////// Check selections for valid choices ////////////////////////////////////////////
bool AGameManager::CheckForStraight(TArray<int> selectedDiceValues)
{
if (selectedDiceValues.Num() == 6)
{
// Sort the array so like values are next to each other
selectedDiceValues.Sort();
for (int i = 0; i < selectedDiceValues.Num() - 1; i++)
{
// If the value is the same as the one after it return false
if (selectedDiceValues[i] == selectedDiceValues[i + 1])
{
return false;
}
}
// If the for loop completed, return true
return true;
}
// if the array length is less than 6 return false because a straight must be all 6 in a row
else
{
return false;
}
}
bool AGameManager::CheckForOnesAndFives(int selectedDiceValue)
// Call this function inside a for loop iterating through all the selected dice
{
if (selectedDiceValue == 1)
{
return true;
}
else if (selectedDiceValue == 5)
{
return true;
}
else
{
return false;
}
}
bool AGameManager::CheckForThreeOfAKind(TArray<int> selectedDiceValues)
{
if (TOAK1.Num() != 0)
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Green, TEXT("GM: TOAK WAS GREATER THAN 0!!!!!"));
TOAK2 = TOAK1;
TOAK1.Empty();
}
if (selectedDiceValues.Num() >= 3)
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Green, TEXT("GM: Selected values list is greater than 3 or equal"));
// Sort the array so that like values are next to each other and in order
selectedDiceValues.Sort();
for (int i = 0; i < selectedDiceValues.Num(); i++)
{
// If there are 3 values to compare
if (selectedDiceValues.IsValidIndex(i+1) && selectedDiceValues.IsValidIndex(i+2))
{
if (selectedDiceValues[i] == selectedDiceValues[i + 1] && selectedDiceValues[i] == selectedDiceValues[i + 2])
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Green, TEXT("GM: Found a 3 of a kind"));
TOAK1.Add(selectedDiceValues[i]);
TOAK1.Add(selectedDiceValues[i + 1]);
TOAK1.Add(selectedDiceValues[i + 2]);
return true;
}
else
{
//GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Green, TEXT("GM: values are not equal"));
//return false;
}
}
}
// If the for loop completes without returning true, then return false
return false;
}
else
{
return false;
}
}
bool AGameManager::CheckForThreePairs(TArray<int> selectedDiceValues)
{
int numPairs = 0;
if (selectedDiceValues.Num() == 6)
{
selectedDiceValues.Sort();
for (int i = 0; i < selectedDiceValues.Num(); i = i + 2)
{
if (selectedDiceValues[i] == selectedDiceValues[i + 1])
{
numPairs = numPairs + 1;
}
else
{
return false;
}
}
if (numPairs == 3)
{
return true;
}
else
{
return false;
}
}
return false;
}
int AGameManager::CalculateScore(TArray<int> selectedDiceValues)
{
int score = 0;
// If the array is full, AKA there are 6 values
if (selectedDiceValues.Num() == 6)
{
// Check for straight and 3 pairs first
bool straight = CheckForStraight(selectedDiceValues);
bool threePairs = CheckForThreePairs(selectedDiceValues);
// If there are no straights or three pairs then check for three of a kind
if (!straight && !threePairs)
{
// If there is a three of a kind then sort the array and remove those values from the array
if (CheckForThreeOfAKind(selectedDiceValues))
{
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("GM: Three of a Kind found"));
selectedDiceValues.Sort(); // Probably not necessary
// Set this because the CheckForThreeOfAKind function modifies TOAK1
TOAK2 = TOAK1;
TOAK1.Empty();
// Remove values from selectedDiceValues that are included in the three of a kind array
// to avoid double counting.
for (int i = 0; i < TOAK2.Num(); i++) // Outer loop limits the number of removals to three.
// If there's another toak of the same value, or if it's a toak of either a 1 or a 5, this prevents ALL instances of that value being removed
{
for (int j = 0; j < selectedDiceValues.Num(); j++) // Iterate through each value in the selectedDiceValues array to check if it's the same as the val in the three of a kind
{
if (selectedDiceValues[j] == TOAK2[i])
{
// If so, remove it
selectedDiceValues.RemoveAt(j);
}
}
}
// If there is a second three of a kind
if (CheckForThreeOfAKind(selectedDiceValues))
{
score = score + CalculateThreeOfAKind();
}
}
// Add the three of a kind score
score = score + CalculateThreeOfAKind();
GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, TEXT("GM: calculatd toak"));
score = score + CalculateOnesAndFives(selectedDiceValues);
}
else if (straight)
{
score = 3000;
}
else if (threePairs)
{
score = 1500;
}
}
else if (selectedDiceValues.Num() > 2 && selectedDiceValues.Num() < 6)
{
// If there is a three of a kind then remove those values from the array
if (CheckForThreeOfAKind(selectedDiceValues))
{
// Remove values from selectedDiceValues that are included in the three of a kind array
// to avoid double counting.
for (int i = 0; i < TOAK1.Num(); i++)
{
for (int j = 0; j < selectedDiceValues.Num(); j++)
{
if (selectedDiceValues[j] == TOAK1[i])
{
selectedDiceValues.RemoveAt(j);
}
}
}
// Make sure only one of the toak arrays is filled, since the check happens twice, but it's not possible to have 2 toaks
TOAK2.Empty();
score = score + CalculateThreeOfAKind();
//score = score + CalculateOnesAndFives(selectedDiceValues);
}
score = score + CalculateOnesAndFives(selectedDiceValues);
}
// If there are less than 3 values in the array, it must only be ones or fives
else if (selectedDiceValues.Num() > 0 && selectedDiceValues.Num() < 3)
{
score = CalculateOnesAndFives(selectedDiceValues);
}
return score;
}
int AGameManager::CalculateOnesAndFives(TArray<int> selectedDiceValues)
{
int score = 0;
for (int i = 0; i < selectedDiceValues.Num(); i++)
{
if (selectedDiceValues[i] == 1)
{
score = score + 100;
}
else if (selectedDiceValues[i] == 5)
{
score = score + 50;
}
}
return score;
}
int AGameManager::CalculateThreeOfAKind()
{
int score = 0;
int toak2Val = 0;
int toak1Val = 0;
if (TOAK2.Num() != 0)
{
toak2Val = TOAK2[0];
}
if (TOAK1.Num() != 0)
{
toak1Val = TOAK1[0];
}
if (toak1Val == 1)
{
toak1Val = 1000;
toak2Val = toak2Val * 100;
score = score + toak1Val + toak2Val;
}
else if (toak2Val == 1)
{
toak2Val = 1000;
toak1Val = toak1Val * 100;
score = score + toak1Val + toak2Val;
}
else
{
toak2Val = toak2Val * 100;
toak1Val = toak1Val * 100;
score = score + toak1Val + toak2Val;
}
return score;
}
AI
From the beginning, I have planned implement an AI system so that the game could be played “single-player.” While this feature has not been implemented yet, I have created the foundations for this feature. Much of the code was written to account for an AI player (or 3). Additionally, I have written pseudocode and outlines for the logic of the AI.
What’s Next?
This is the first version of the prototype. The main gameplay loop is complete, and the game can be fully played with no game-breaking bugs, which is a good first step. The next steps involve making the game more engaging, removing bugs, and implementing AI!
Bug Fixes & Other Needed Changes
Priority issues include:
- There is an issue when calculating 3-pairs. Example observed: (4, 4, 4, 4, 2, 2) resulted in a valid selection if selecting all dice, but the score it added was not correct.
- Round Score only displays the score of the current selection. In the backend the logic is correct and the scores are calculated correctly, but visually it is confusing.
- Need to add an indicator to represent whose turn it is at any given time.
- Rarely, a dice will get stuck on the edge of the mesh, this causes the editor to crash. I’ve implemented a potential fix, but I haven’t been able to observe the bug happen in game since then.
Gameplay Improvements
After the priority bug fixes have been completed, I can start planning and making changes to the gameplay.
Plans for future development:
- AI Implementation.
- Levels of difficulty (more or less risk?)
- Add an optional player name.
- Data persistence for saving high scores, and saving game progress.
- Additional game modes or ways to modify the rules.
- Ex. Make the game faster by lowering the win amount from 10,000. Or vice-versa make the game longer by raising it.
- Customization for your player profile. Change the color of your scoreboard or the look of your dice.