Attic-Panic screenshot
Attic-Panic screenshot
Attic-Panic screenshot
Attic-Panic screenshot
Attic-Panic screenshot

Attic Panic

Attic Panic is a 2-4 player top-down supernatural rogue-lite shooter where you play as possessed toys that battle each other to entertain the Entity, which grants players unique powers and abilities at the end of each round.

The game has been in development for a year with a team size of 27 people to simulate an indie studio environment.

The goal for this project was to tackle the 4 stages of game development and to release the final product on Steam.

role icon

Lead Programmer
DevOps Engineer

team icon

27 people

time icon

1 year

platform icon

Windows

platform icon

Unreal Engine 5.1

platform icon

Steamworks
Discord SDK

Lead Responsibilities

Throughout the entire year long project, I was assigned as Programmer Lead of Team Tonk. Some of my responsibilities as PR lead included: Keeping track of all programmer tasks, ensuring code standards are upheld, putting the right people on the right task, risk management and main line of communication with other leads.

Due to all the PR lead responsibilities, I wasn't able to work much on the project directly myself. however, being able to have a high overview of the project and delegating work was a unique experience I otherwise wouldn't have gotten to experience.

One of my biggest struggles with being the programmer lead was to find the balance of programmer and lead. To tackle this, I would make sure to put my programmers on tasks that had no direct influence on my role as lead.

This resulted in me still having a small bit of time to work on tasks myself, like implementing the Jenkins build pipeline, Upgrades and the Analytics Tool.

Attic-Panic picture

Jenkins

Besides my responsibility as programmer lead, I was also responsible for the Jenkins pipeline within the project. Since I was the only person in the team with knowledge about Jenkins and Groovy, I was tasked with setting this up.

Starting off with the project, I first created a simple pipeline that would allow us to build our Unreal Engine project. Throughout development however, the pipeline expended to allow for pushing to Steam from Jenkins.

After adding the Steam upload to our groovy script, I also began with running tests from Unreal Engine. To make sure no unstable build would be pushed to Steam, we would first run the test and skip the Steam upload if the project was unstable.

To know what went wrong whenever our build was unstable, I decided to add functionality to push the test results to Google Drive. And as both my programmers and I wanted to have easy access to the drive, I made custom discord messages that would directly link to the drive.

Jenkins pipeline script for the release branch written in Groovy
// Fetch Shared Library from Github
library identifier: 'JenkinsSharedLib@master',
retriever: modernSCM([
$class: 'GitSCMSource',
credentialsId: '', // Public repo, no credentials needed
remote: 'https://github.com/DavidtKate/JenkinsSharedLib'
])
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
@NonCPS
def jsonParser(file) {
def testResults = new JsonSlurper().parseText(file)
def success = testResults.succeeded
def warning = testResults.succeededWithWarnings
def failed = testResults.failed
def total = success + warning + failed
def results = [success, warning, failed, total]
return results
}
pipeline {
agent {
// Set custom Workspace
node {
label ""
customWorkspace "C:\\Jenkins\\${env.JOB_NAME}"
}
}
environment {
// Variables are kept mostly empty for sharing purposes
// Perforce
P4USER = ""
P4HOST = ""
P4WORKSPACE = "Jenkins_Workspace"
P4MAPPING = ""
// Configuration
CONFIG = "Shipping" // Target list: Unknown, Debug, DebugGame, Development, Shipping, Test
PLATFORM = "Win64" // Platform list: Win32, Win64, Linux
// Unreal Engine 5
ENGINEROOT = ""
PROJECT = ""
PROJECTNAME = ""
OUTPUTDIR = "${env.WORKSPACE}\\Output"
// Steam
STEAMUSER = ""
STEAMCMD = "C:\\Steam\\steamcmd\\steamcmd.exe"
// Google Drive
GDAUTH = ""
GOOGLEDRIVEID = ""
// Discord
WEBHOOK_BUILD = ""
}
stages {
stage('P4-setup') {
steps {
script {
log.currStage()
p4v.init(env.P4USER, env.P4HOST, env.P4WORKSPACE, env.P4MAPPING)
}
}
}
stage("Build") {
steps {
script {
log.currStage()
ue5.build(env.ENGINEROOT, env.PROJECTNAME, env.PROJECT, env.CONFIG, env.PLATFORM, env.OUTPUTDIR)
}
}
}
stage("Run Tests") {
steps {
script {
if(currentBuild.currentResult == "SUCCESS") {
log.currStage()
ue5.runNamedTests(["Project.Functional Tests"], env.CONFIG, env.PLATFORM)
//Dont upload the test files if all tests succeeded
if(currentBuild.currentResult == "UNSTABLE") {
try {
//Copy and rename the file to the build number
def filePath = "${env.WORKSPACE}\\Logs\\UnitTestsReport\\"
bat(label: "Copy and rename JSON file", script: "copy /y \"${filePath}index.json\"
\"${filePath}${env.JOB_BASE_NAME}_${env.BUILD_NUMBER}.json\"")
//Push results to Google Drive
withCredentials([file(credentialsId: '', variable:
'SECRETFILE')]) {
bat(label: "Upload test results to Google Drive", script: "python
\"C:\\Users\\Administrator\\Desktop\\Scripts\\GoogleDriveUpload.py\" \"${SECRETFILE}\"
\"${filePath}${env.JOB_BASE_NAME}_${env.BUILD_NUMBER}.json\"
\"${env.JOB_BASE_NAME}_${env.BUILD_NUMBER}.json\" \"${env.GOOGLEDRIVEID}\" 16")
}
}
catch (Exception e) {
log.warning("Unable to send the test results to Google Drive!")
}
}
}
else {
log.error("Test was not ran due to the build failing")
}
}
}
}
stage("steam-deploy") {
steps {
script {
log.currStage()
if(currentBuild.currentResult == "SUCCESS") {
steam.init(env.STEAMUSER, env.STEAMCMD)
def appManifest = steam.createAppManifest(
"2168760",
"2168761",
"",
"${env.JOB_BASE_NAME} (Build ${env.BUILD_NUMBER})",
false,
"",
"jenkins",
env.OUTPUTDIR
)
steam.createDepotManifest("2168761", "${env.OUTPUTDIR}\\Windows")
steam.tryDeploy("${env.WORKSPACE}\\${appManifest}")
}
else if(currentBuild.currentResult == "UNSTABLE") {
log.warning("Current build is not pushed to Steam as it was unstable!")
}
else {
log.error("Current build is not pushed to Steam as it failed to build!")
}
}
}
}
}
post {
success {
script {
log("Build succeeded")
def result = jsonParser(ue5.getTestResults())
def color = "green"
discord.sendMessage(discord.createMessage(":white_check_mark: BUILD SUCCEEDED :white_check_mark:",
color,
[[name:"${config}(${platform}): has succeeded",
value:"Last Changelist: ${env.P4_CHANGELIST}"],
[name:":white_check_mark: Succeeded",
value:"${result[0]}/${result[3]}"],
[name:":warning: Succeeded with warnings",
value:"${result[1]}/${result[3]}"],
[name:":x: Failed",
value:"${result[2]}/${result[3]}"]],
[text:"${env.JOB_BASE_NAME} (${env.BUILD_NUMBER})"])
, env.WEBHOOK_BUILD)
}
}
unstable {
script {
log("Build unstable")
def result = jsonParser(ue5.getTestResults())
def color = "yellow"
discord.sendMessage(discord.createMessage(":warning: UNSTABLE BUILD - PUSH TO STEAM ABORTED :warning:",
color,
[[name:"${config}(${platform}): has succeeded, but is unstable",
value:"Last Changelist: ${env.P4_CHANGELIST}"],
[name:"Test result: ${env.JOB_BASE_NAME}_${env.BUILD_NUMBER}",
value:"https://drive.google.com/drive/"], //Removed fill link for sharing
[name:":white_check_mark: Succeeded",
value:"${result[0]}/${result[3]}"],
[name:":warning: Succeeded with warnings",
value:"${result[1]}/${result[3]}"],
[name:":x: Failed",
value:"${result[2]}/${result[3]}"]],
[text:"${env.JOB_BASE_NAME} (${env.BUILD_NUMBER})"])
, env.WEBHOOK_BUILD)
}
}
failure {
script {
log("Build failed")
discord.sendMessage(discord.createMessage(":x: BUILD FAILED - PUSH TO STEAM ABORTED :x:",
"red",
[[name:"${config}(${platform}): has failed",
value:"Last Changelist: ${env.P4_CHANGELIST}"],
[name:"Job url",
value:"${env.BUILD_URL}"]],
[text:"${env.JOB_BASE_NAME} (${env.BUILD_NUMBER})"])
, env.WEBHOOK_BUILD)
}
}
aborted {
cleanWs()
}
cleanup {
cleanWs()
}
}
}
view raw pipeline.groovy hosted with ❤ by GitHub

Upgrades

Besides my responsibility as PR Lead, I also contributed to the project by creating upgrade prototypes. As we had too few prototypes to work with, we decided to just create a lot of upgrades to see what sticks. 

One of the upgrades that I made was the thorns upgrade. When chosen, the upgrade would attach a collision sphere to the player that when collided with another player, will damage them continuously until they move out of the way.

This would create a sense of danger towards to the other players as they now had to watch out for not only the bullets, but also the hull.

I also worked on the fire track and ice track prototypes. When a player obtains one of these two upgrades, a trail of collision boxes would spawn behind them and would destroy itself after a certain amount of time had expired.

When a player would drive over the ice tracks, their movement would be slowed and they would become more slippery. When driven over the fire tracks, the player would take continuous damage until they moved out of the fire tracks.

As both systems work very similarly, I decided to create a track base blueprint where both upgrades could inherit from. By doing this, I both reduced my time developing the prototypes while also allowed expansion of other track based upgrades.

Discord

Attic Panic is an online multiplayer party game, and because of this, it is important to keep the community that plays the game alive. One of the ideas that I came up with was to implement discord activity of our game, so that others can see when our game is being played. This would help people engage with each other more, trying to host lobbies and join them, resulting in more longevity of our game.

The Discord Activity status was implemented by me within UE5 using the Discord SDK. Using the small UE4 example provided by Discord, I was able to link the SDK to our game in UE5 albeit with some trouble regarding the limited documentation and outdated example.

Once the Discord SDK was linked to our project, I made some functions regarding the initialization, updating and activity changes of Discord. To be able to call these functions, I created an actor that could be placed in each level that would change the discord status of the players in that level. By doing this, the activity of the players would always be correctly displayed.

Discord functions for UE5 written in C++
// Fill out your copyright notice in the Description page of Project Settings.
#include "DiscordFunctions.h"
#include "Kismet/GameplayStatics.h"
#include "discord.h"
discord::Core* DiscordCore{};
void UDiscordFunctions::InitializeDiscord(const UObject* WorldContextObject)
{
discord::ClientId ClientId = [API client ID]; //Hidden for sharing purposes
discord::Result Result = discord::Core::Create(ClientId, DiscordCreateFlags_Default, &DiscordCore);
if (Result != discord::Result::Ok)
{
UKismetSystemLibrary::PrintString(WorldContextObject, "Error initializing discord", true, true, FColor(255, 255, 255, 255), 10.f);
}
}
//Call every tick to update the discord status
void UDiscordFunctions::UpdateDiscordData(const UObject* WorldContextObject)
{
::DiscordCore->RunCallbacks();
}
//Change discord activity
void UDiscordFunctions::UpdateDiscordStatus(const UObject* WorldContextObject, EDiscordGameState GameState, int32 PlayerCount)
{
discord::Activity Activity{};
Activity.GetAssets().SetLargeImage("atticpanic");
Activity.GetAssets().SetLargeText("Attic Panic");
Activity.SetType(discord::ActivityType::Playing);
switch (GameState)
{
case EDiscordGameState::Menu:
{
Activity.GetParty().SetPrivacy(discord::ActivityPartyPrivacy::Private);
Activity.SetState("In Menu");
Activity.SetDetails("");
}
break;
case EDiscordGameState::Lobby:
{
Activity.GetParty().SetPrivacy(discord::ActivityPartyPrivacy::Public);
Activity.SetState("In Lobby");
Activity.SetDetails("Waiting For Players");
Activity.GetParty().GetSize().SetCurrentSize(PlayerCount);
Activity.GetParty().GetSize().SetMaxSize(4);
}
break;
case EDiscordGameState::Game:
{
Activity.GetParty().SetPrivacy(discord::ActivityPartyPrivacy::Public);
Activity.SetState("In The Attic");
Activity.SetDetails("Collecting Essence");
Activity.GetParty().GetSize().SetCurrentSize(PlayerCount);
Activity.GetParty().GetSize().SetMaxSize(4);
}
break;
}
DiscordCore->ActivityManager().UpdateActivity(Activity, [](discord::Result result) {});
}