Okainos was a multiplayer card game inspired by Hearthstone. I was recruited as lead developer by an artist I met on a game jam to help transition the game from alpha to closed beta.
I built game systems and networking tools, teaching me how to use back-end technologies like Rest API, JavaScript, JSON, and Firebase's suite of tools, including the Realtime Database, Authentication, Hosting, and Cloud Storage.
One major challenge we faced was to see how all systems performed in action. To address this, we created a website to attract beta testers, leveraging Firebase's free web hosting services. Additionally, I created networking editor tools to automate processes, streamlining the game's development and the testing process.
In order to save the card deck created by a player, a card deck code containing the details is imported and exported by the game. The code was designed with simplicity in mind for sharing and manual editing. The format goes like this:
// Multiple cards seperated by commas
CARD1_NAME*QUANITY,CARD2_NAME*QUANITY,CARD3_NAME*QUANITY
Here you can see changes to card decks being saved to the database.
The following code is used to decode the player’s card deck from the database, and validate created card decks by comparing against the player’s card collection to see if all selected cards is available before encoding.
// Convert list to card deck code
// Returns a card deck code string
public string GetCardDeckCode()
{
string cardDeckCode = "";
if (cardItems.Count > 0)
{
foreach (CardInDeckItem cardItem in cardItems)
{
// Returns id and quantity seperated by "*" and ","
cardDeckCode += new CardInGroup(cardItem.CardData.id, cardItem.CardQuantity).ToString();
}
cardDeckCode.Remove(cardDeckCode.Length - 1, 1);
}
return cardDeckCode;
}
Card Deck Decoder
// Convert card deck code to a list of cards through validation
// Returns a list of deck cards
public CardInGroup[] DecodeCardDeckCode()
{
// Array of cards
string[] cardIdsAndQuantities = code.Split(',', '*');
var qseudoDeckCards = new CardInGroup[cardIdsAndQuantities.Length / 2];
// Validate card details (id, quantity)
for (int i = 0; i < qseudoDeckCards.Length; i++)
{
int quantity;
// Get the quantity from text
if (int.TryParse(cardIdsAndQuantities[(i * 2) + 1], out quantity))
{
// Enforce max quantity of 20 per card - discard cards with invalid quantity
if (quantity > 0 && quantity <= 20)
{
qseudoDeckCards[i] = new CardInGroup(cardIdsAndQuantities[i * 2], quantity);
}
}
}
return qseudoDeckCards;
}
After importing the card deck code and deserialising it to card objects, the cards are compared against the player's card collection, which has been fetched from the database for further validation of the deck before use.
In order to reduce the amount of updates testers had to install from the game page, I implemented card data assets, used to store card details like stats, effects, description, etc. This allowed card nerfs or buffs to be updated across all devices behind the scenes.
The data is written and read in JSON between the editor and database and custom editor tools were used to automate the process.
However, this is not suitable for card resource files like materials as they much more complex than simple data types like strings or integers. A solution could be to store resources in Firebase Cloud Storage and fetch changes from there, or use a launcher app.
I also built a custom property drawer to apply abstraction for card editing by hiding properties of unselected card effects that wont be stored in the database. This was needed since each card effect had its own unique set of properties.
This custom property drawer has been made flexible, not for only card effects, but also for the selection of the card effect "conditions". It's also used differently for the editing of "story events", used to manage the dialogue for the campaign mode of the game.
using UnityEngine;
using UnityEditor;
using Story;
[CanEditMultipleObjects]
public abstract class PropertySelectorDrawer : PropertyDrawer
{
protected virtual string[] GetPropertyHeightOfTypes => new string[0];
protected abstract string SelectorPropertyName { get; }
protected abstract string GetEnumString(int enumIndex, bool lowercaseFirstChar = false);
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
SerializedProperty selectorProperty = GetSelectorProperty(property);
var selectorRect = new Rect(position.x, position.y, position.width, EditorGUI.GetPropertyHeight(selectorProperty));
EditorGUI.PropertyField(selectorRect, selectorProperty, GUIContent.none);
SerializedProperty selectedProperty =
property.FindPropertyRelative(GetEnumString(selectorProperty.intValue, true));
if (selectedProperty != null)
{
EditorGUI.PropertyField(position, selectedProperty, GUIContent.none, true);
}
else
{
Debug.LogWarning("Property " + selectedProperty.name + " does not exist");
}
}
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
SerializedProperty tempProperty;
float totalHeight = 0;
for (int i = 0; i < GetPropertyHeightOfTypes.Length; i++)
{
tempProperty = property.Copy();
while (tempProperty.NextVisible(true))
if (tempProperty.type == GetPropertyHeightOfTypes[i])
{
totalHeight += EditorGUI.GetPropertyHeight(tempProperty, true);
break;
}
}
tempProperty = property.Copy();
while (tempProperty.NextVisible(true))
if (tempProperty.type == GetEnumString(GetSelectorProperty(property).intValue))
{
totalHeight += EditorGUI.GetPropertyHeight(tempProperty, true);
break;
}
return totalHeight;
}
protected void LowercaseFirstChar(ref string str)
{
str = char.ToLower(str[0]) + str.Substring(1);
}
protected SerializedProperty GetSelectorProperty(SerializedProperty property)
{
return property.FindPropertyRelative(SelectorPropertyName);
}
}
[CustomPropertyDrawer(typeof(StoryEvent))]
public class StoryEventPropertySelectorDrawer : PropertySelectorDrawer
{
protected override string SelectorPropertyName => "EventType";
protected override string GetEnumString(int enumIndex, bool lowercaseFirstChar = false)
{
string enumString = ((StoryEvent.EventTypeEnum)enumIndex).ToString();
return enumString;
}
}
[CustomPropertyDrawer(typeof(CardEffectEdit))]
public class CardEffectEditPropertySelectorDrawer : PropertySelectorDrawer
{
protected override string SelectorPropertyName => "effectTag";
protected override string[] GetPropertyHeightOfTypes => new[] { typeof(CardEffectConditionEdit).ToString() };
protected override string GetEnumString(int enumIndex, bool lowercaseFirstChar = false)
{
string enumString = ((CardEffect.Tag)enumIndex).ToString();
if (lowercaseFirstChar)
{
LowercaseFirstChar(ref enumString);
}
return enumString;
}
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
base.OnGUI(position, property, label); // Show card effect tag and properties
// Show card effect conditions edit
SerializedProperty selectedProperty =
property.FindPropertyRelative(GetEnumString(GetSelectorProperty(property).intValue, true));
position.y += EditorGUI.GetPropertyHeight(selectedProperty);
EditorGUI.PropertyField(position, property.FindPropertyRelative("conditionsEdit"), new GUIContent("Conditions Edit"), true);
}
}
[CustomPropertyDrawer(typeof(CardEffectConditionEdit))]
public class CardEffectConditionEditPropertySelectorDrawer : PropertySelectorDrawer
{
protected override string SelectorPropertyName => "conditionTag";
protected override string GetEnumString(int enumIndex, bool lowercaseFirstChar = false)
{
string enumString = ((CardEffect.Condition.Tag)enumIndex).ToString();
if (lowercaseFirstChar)
{
LowercaseFirstChar(ref enumString);
}
return enumString;
}
}
To further reduce updates, we can put a launcher app on the game page which will automatically retrieve updated versions of the game build (along with card resources) from the Firebase Cloud Storage.
Unfortunately, I ran into issues during testing as the Firebase project has reached its monthly limit of downloads. So we decided to put off the idea for now.
You can gain more cards with boosters, which can be bought in the shop. The number of boosters you have along with your expanding card collection is updated in your account.
Creative use of the Gradient component is made for visualising and editing the card rarity possibility allowing for unique chances for each booster chest type.
An essential system I had to implement for the website was authentication. This was linked to the same database allowing people to login with the same account created on the website or the game!
In order to private the game's access to only beta testers, I used Itch.io's key group feature to generate a max of 100 keys and stored them in Firebase Cloud Storage. For each account created on the website, after they pass the verification process via email, a key URL is pulled from the list and stored in the account for them to claim and access the game page with it from their profile page.
This function is called upon pressing submit in the register page.
function OnRegisterInput()
{
if (registerFormStep == 1) // Sign in mode
{
// Get user input
email = registerForm['emailInput'].value;
password = registerForm['passwordInput'].value;
// Check if user already exists in the database, if so sign in, else sign up new account
get(refDB(database, 'users/')).then(dataSnapshots =>
{
const users = keyfullObjectSnapshotToArray(dataSnapshots);
users.forEach(user =>
{
if (email == user.email)
{
if(ValidateSignIn(email, password, users)) // Check if details are valid
{
SignIn(email, password);
}
return;
}
});
if (ValidateSignUpEmailPassword(email, password, users)) // Check if details are valid
setupRegisterFormInputs(registerFormStep = 2); // Setup register for sign up
});
}
else if (registerFormStep == 2) // Sign up mode
{
get(refDB(database, 'users/')).then(dataSnapshots =>
{
const users = keyfullObjectSnapshotToArray(dataSnapshots);
const username = registerForm['usernameInput'].value;
if (ValidateSignUpUsername(username, users)) // Check if username already exists
{
SignUp(email, password, username);
}
});
}
}
This function is called after the player verified their account
function GetClosedBetaTesterKey()
{
const userClosedBetaTesterItchIoKeyURLRef = refDB(database, "users/" + auth.currentUser.uid + "/closedBetaTesterItchIoKeyURL");
get(userClosedBetaTesterItchIoKeyURLRef)
.then(userClosedBetaTesterKeyResponse =>
{
var urlKey = userClosedBetaTesterKeyResponse.val();
// Check if user already has a key, if so then assign them one, else refresh the game page button with the key url
if (urlKey == "")
{
// Add user to mail list
SubscribeEmailToList(user.email);
// Get keys list from Firebase Cloud Storage
const closedBetaTesterKeysRef = refGS(storage, 'closed-beta-tester-keys.txt');
getDownloadURL(closedBetaTesterKeysRef).then((url) =>
{
fetch(url).then(keysResponse =>
{
keysResponse.text().then(keysText =>
{
// Grab first key
var nextKey = keysText.split('\n')[0];
// Exclude the key from the list
keysText = keysText.substring(nextKey.length + 1)
uploadString(closedBetaTesterKeysRef, keysText);
// Assign player with key
set(userClosedBetaTesterItchIoKeyURLRef, nextKey)
.then((response) =>
{
urlKey = nextKey
RefreshSendClosedBetaTesterKeyButton(urlKey);
});
});
});
});
}
else
{
RefreshSendClosedBetaTesterKeyButton(urlKey);
}
});
}
The remaining number of seats available was also display scroll-transitioned on the site to induce urgency.
To help players strategize, I created a team planner with a filtering system that can sort cards by tribe, rarity, strength, price, draw. For further filtering, you can also check to only see cards owned in your collection or search by name.
Other functionalities include the ability to trade craft and scrap cards for salt and pearls.
A big issue we faced when testing a build of the game was that it would fail to load card resources like materials, sprites, and prefabs. This was due to the old method of storing the GUID of the assets on the database and using it to load the assets at runtime. Since this uses the UnityEditor namespace it does not work in a build.
The solution I implemented was a resources manager which uses Unity’s persisting resources folder which is loaded for retrieving the correct card resources by ID. This saved a lot of space in the database as we are no longer storing ID for each resource and instead use the card's ID to retrieve resources from the persisting folder.
public static class CardResourcesManager
{
public static Dictionary cardResourcesDictionary;
public static void LoadCardResources()
{
cardResourcesDictionary = new Dictionary();
Resources.LoadAsync("Cards");
CardAsset.Data[] cardDatas = PersistentData.cardDatas;
StaticCoroutine.StartCoroutine(LoadAll(cardDatas));
Debug.Log("Card resources reloaded!");
}
public static IEnumerator LoadAll(CardAsset.Data[] cardDatas)
{
for (int i = 0; i < cardDatas.Length; i++)
{
CardAsset.Data cardData = cardDatas[i];
yield return new WaitForEndOfFrame();
RescanCardDirectory(cardData);
}
}
static void RescanCardDirectory(CardAsset.Data cardData)
{
// Directories
string cardsDir = "Cards/";
string cardFamilyDir = cardsDir + "Tribes/" + cardData.familly + "/";
string cardDir = cardFamilyDir + cardData.name + "/";
string cardRarityDir = cardsDir + "Rarities/" + cardData.rarity + "/";
string cardMaterialDir = cardsDir + "Materials/";
// Load from resources folder
//
var basicMaterial =
(Material)Resources.Load(cardDir + cardData.name + " Basic", typeof(Material));
var battlePrefab =
(GameObject)Resources.Load(cardDir + cardData.name + " Battle", typeof(GameObject));
// Illustration resources
var deckIllustrationSubject =
(Sprite)Resources.Load(cardDir + cardData.name + " Subject", typeof(Sprite));
var deckIllustrationBackground =
(Sprite)Resources.Load(cardDir + cardData.name + " Background", typeof(Sprite));
var familyIcon =
(Sprite)Resources.Load(cardFamilyDir + cardData.familly + " Icon", typeof(Sprite));
// Layout resources
var rarityLayoutSprite =
(Sprite)Resources.Load(cardRarityDir + cardData.rarity + " Layout Sprite", typeof(Sprite));
var rarityLayoutMaterial =
(Material)Resources.Load(cardRarityDir + cardData.rarity + " Layout Material", typeof(Material));
var raritySummonEFX =
(GameObject)Resources.Load(cardRarityDir + cardData.rarity + " Summon EFX", typeof(GameObject));
var familyMaterial =
(Material)Resources.Load(cardMaterialDir + "Family", typeof(Material));
// Contain resources
var rarityResources =
new CardRarityResources(new CardRarityResources.Layout(rarityLayoutSprite, rarityLayoutMaterial),
new CardRarityResources.EFX(raritySummonEFX));
var resources =
new CardResources(basicMaterial, battlePrefab, familyIcon, null,
deckIllustrationSubject, deckIllustrationBackground, rarityResources, familyMaterial);
// Map id to resources
string id = "";
foreach (CardAsset.Data cardDataFromDB in PersistentData.cardDatas)
{
if (cardDataFromDB.name == cardData.name)
{
id = cardDataFromDB.id;
}
}
cardResourcesDictionary.Add(id, resources);
}
public static CardResources GetCardResources(string cardId) => cardResourcesDictionary[cardId];
}