Okainos

    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.

    Launched Closed-Beta Testing Campaign through Web Dev!

    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.

    OKAINOS

    Card Deck Builder - Serialising & Deserialising Custom Code

    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.

    FRONT-END

    BACK-END

    Card Deck Code SerDes & Validation

    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.

    Reducing Updates with Card Data Assets (& Launcher App)

    Card Data Assets

    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.

    PropertySelectorDrawer

    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;
    	}
    }

    Launcher App

    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.

    Booster Opening

    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.

    FRONT-END

    BACK-END

    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.

    Website Auth System (Firebase, Email & Itch.io)

    Firebase Authentication & Itch.io Integration

    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.

    Authentication Process

    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.

    Card Collection Filtering & Trading Tools

    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.

    FRONT-END

    BACK-END

    Managing Heavy Resources (materials, sprites, prefabs)

    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.

    CardResourcesManager
    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];
    }