Jump directly to main content

Project 1: ECSey Code Restructure



Believe me when I say, the hack in code hurts me more than it hurts you. With that said, it was essential to getting the core in, and can easily be re-written, which I started on immediately after it's completion.

Data Oriented?

While creating the core of the game, even during the hack in I had a somewhat data-oriented approach, with the use of arrays, and objects of pure data scattered around rather than classes that could bulk and slow the game down.

As it was hacky though, it eventially become that there were too many arrays with too much similar data, that were accessed slightly differently.

This led me to look into slowly writting the code base over, replacing individual arrays and functions with more universal data, with each piece of data only being referrenced once, and any functionality directly working with that data.

ECSey

With my mind in a semi data oriented mode, I thought of other games (and performant required software) and how they would typically implement an ECS (Entity Component System), and I opted to go the same route.

I have never written an ECS, and didn't look into it, as I vaguely understood what's what and the purpose. Since I did this however I'm unsure if I ended up with a 'true ECS', so I opted to refer to my codebase as 'ECSey'.

Here's how it works, mostly.

The Breakdown

Entity

The 'object' itself, these are stored in an 'item' array, and are stored only as an integer, no other data at all.

item = [0,1,2,3,4];

Component

The 'attributes' of the Entity. These contain the actual data for each of the 'objects'/entities. They are stored in an array (an object in JS) and are accessed by referring to said array with the key of the 'entity'/item ID the attribute belongs to.

size[entityId] = [width, height]; position[itemKey] = [positionX,positionY];

System

Each system consists of the function calls, and processes that access and perform actions with the entity's attributes, iterating each of them and performing the actions according to their data. So, for instance I have a 'drawItems' function that loops each item, and draws that item to the board based on the entity's size, and position (if it has then both set).

ECSey code examples

And here are some snippets of what the new codebase looks like at current. Still not finalised, but so much better.

Basic Components

As this is the first pass for the ECSey design, I replicated the existing arrays and objects into components/attributes. This is not all there will be in the future, in fact this list has more than doubled already!

let item		= [];
let itemCount		= 0; // Used to keep track of how many items there have been

let boardElement	= {};
let cardData		= {};
let position		= {};
let size		= {};
let cardStatus		= {};
let player		= {};
let listPosition	= {};

Get Items

This loop all the entities, compares the passed 'boardElement', 'playerId', 'cardStatus', 'listPosition', and any other attributes passed, and returns a new array of only the entities that match the criteria.

getItems(playerId = null, boardElementId = null, ... ){

	let newItems	= item; // Set array to all items (item is current name of entities array)
	let tempArray	= []; // New array for filtering

	// Check if each item shares the PLAYERID passed
	if(playerId !== null){ // Only check if a playerId has been passed to getItems

		for(let newItem = 0; newItem < newItems.length; newItem++){

			let itemKey = newItems[newItem]; // Get the entity key/ID

			// If the item shares the playerId, add it to the tempArray
			if(playerId == player[itemKey]){
				tempArray.push(newItems[newItem]);
			}

		}

		// Set newItems to tempArray so it can be looped again with only what matched
		newItems = tempArray;
	}
	// Reset tempArray so it can be reused in next loop
	tempArray = [];

	...

	// Return the new specified itemList
	return newItems;

Each of the components passed gets checked and looped in the same manner, slowly filtering out what's not wanted. Then the newItems array is returned to be used however needed.

Get Item Key

Sometimes only a single item is wanted. This can be retried by passing the three core components I have in the game to date.

getItemKey(boardElementId, listPositionId, playerId){

	// This calls getItems from example above, passing the component data
	let itemKey = this.getItems(boardElementId, playerId, null, listPositionId);
	
	if(itemKey.length < 1){ return false; } // Didn't find a key
	if(itemKey.length > 1){ return false } // Found more than 1 key

	// Return first index of array, aka the itemKey
	return itemKey[0];
}

All this does is call getItems, does a little error handling and returns the first (only) entitiy.

This will only return one, unless something gets broken elsewhere, as each player's boardElements have unique 1..X listPositions that get moved up/down when something is added/removed from the boardElement. There are also error messages, but I've ommited them from the snippet.

Playing to boardElement, Board/Mana/Shield

To demonstrate the fact that getItemKey() will only return one entity we'll look at how I switch entities between the 'boardElements'

addFromBoardElement(playerFrom, fromPosition, elementFrom, elementTo, toPosition=null, playerTo=null){

	// If no playerTo provided (typical behavior), pass between elements for same player
	if(playerTo == null){ playerTo = playerFrom; }

	// Check there is only 1 item that exists with the from info
	let itemKey = this.getItemKey(elementFrom, fromPosition, fromPlayer);

	// Check if there is a specific position the item needs to go to
	if(toPosition == null){
		// If not get the next available position of the elementTo
		toPosition = getCurrentPositionAndLength(elementTo, playerTo)[0]+1;
	}

	this.setCardPosition(itemKey, toPosition, elementTo, playerTo, fromPosition, elementFrom, playerFrom);
	this.removeItemStatus(itemKey); // Untap, remove 'attacking' etc.

}

setCardPosition called first decreases the listPosition of anything from the old boardElement after the old listPosition by 1. It then increases the listPositions in the new boardElement up by one for anything equal to or higher than the new position.

Don't worry if it doesn't make sense at first, I wrote it and it took me a while of writing arrays in a physical notebook before I was sure what would work.

setCardPosition(card, newPosition, newElement, newPlayer, oldPosition, oldElement, oldPlayer){

	// Move old boardElement listPositions down
	this.moveElementPositions(0, oldElement, oldPosition, oldPlayer);

	// Move new boardElement listPositions up
	this.moveElementPositions(1, newElement, newPosition, newPlayer);

	// Then fit the card into the new gap that's opened up in new boardElement
	listPosition[card] = newPosition;
	boardElement[card] = newElement;
}

This is what moveElementPositions does.

moveElementPositions(direction, elementFrom, fromPosition, playerFrom){

	// Loop the elementFrom, and move positions up/down by one
	let items = this.getItems(elementFrom, playerFrom, null, null);

	for(let item = 0; item < items.length; item++){
		let itemKey = items[item];

		// Move everything after the old position down
		if(direction == 0 && listPosition[itemKey] > fromPosition){
			listPosition[itemKey]--;
		}
		// Move everything from the new position up
		if(direction == 1 && listPosition[itemKey] >= fromPosition){
			listPosition[itemKey]++;
		}
	}
}

Attacking

Some of the changes to the attacking code, that you may remember was shown in it's prior hacked-in form.

startAttack(itemAttacking){
	if(this.isTapped(itemAttacking)){ return false; }

	this.setEvent('attack', itemAttacking);
	this.setCardStatus(itemAttacking, 'attacking');
}
makeAttack(itemDefending, itemAttacking = null){

	// If itemAttacking not defined, use the item from inEvent
	if(itemAttacking == null){ itemAttacking = inEvent[1]; }

	if(this.isTapped(itemAttacking)){ return false; }

	switch (boardElement[itemDefending]) {

		case 'board':
			let atkAttacker = this.attackOf(itemAttacking);
			let atkDefender = this.attackOf(itemDefending);

			// Does Attacker kill Defender
			if(atkDefender <= atkAttacker){ this.sendToGrave(itemDefending); }

			// Does Defender kill Attacker
			if(atkAttacker <= atkDefender){
				this.sendToGrave(itemAttacking);
				this.endAttackFor(itemAttacking);
			}
			else{ this.endAttackFor(itemAttacking); }

			break;

		case 'shield':
			// If the shield is tapped 'destroy' it
			if(this.isTapped(itemDefending)){ this.destroyShield(itemDefending); }

			// Otherwise tap the shield, so it can be destroyed in future
			else{ this.tapCard(itemDefending); }

			this.endAttackFor(itemAttacking);

			break;
	}

}

And here's the first pass of the eventListener change to start/make attacks.

for(let itemKey = 0; itemKey < item.length; itemKey++){

	if(itemKey in size && itemKey in position && clickableCheck(x,y,itemKey)){

		let playerId = player[itemKey];

		// Check the location of element
		switch(boardElement[itemKey]){

			case 'board':
				// playerBoard
				if(!inEvent && !board.isTapped(itemKey) && playerId == yourPlayerId){
					board.startAttack(itemKey);
					break;
				}
				// opponentBoard
				if(inEvent && inEvent[0] == 'attack' && inEvent[1] != itemKey && playerId != yourPlayerId){
					// Make attack on the card clicked, with card in inEvent[1]
					board.makeAttack(itemKey);
					break;
				}
				
				...

		}

		...
		
		board.drawBoard();
		return true;
	}
}

Helper Functions

These are smaller functions that perform specific tasks, but written so that I can call them and not replicate code. The examples are simple ones, and some are more simplified for the demonstation.

tapCard(itemKey){ cardStatus[itemKey] = 'tapped'; }	
remainingShieldCount(playerId){ return getCurrentPositionAndLength('shield', playerId)[1]; }
isTapped(itemKey){ if(cardStatus[itemKey] == 'tapped'){ return true; } return false; }
isAttacking(itemKey){ if(cardStatus[itemKey] == 'attacking'){ return true; } return false; }
destroyShield(itemKey){

	if(!this.isTapped(itemKey)){ return false; }
	
	let items = this.getItems('shield', player[itemKey], null, null);
	for(let item = 0; item < items.length; item++){

		let itemKey = items[item];

		// If ANY of their shields are untapped, you can't destroy target
		if(!board.isTapped(itemKey)){
			return false;	
		}
	}

	// Shield is now destroyed, move it from shield to owners hand (for the catchup mechanic)
	this.addShieldToHand(player[itemKey], listPosition[itemKey]);
	return true;
}
addShieldToHand(playerFrom = null, listPositionFrom = null){
	this.addFromBoardElement(playerFrom, listPositionFrom, 'shield', 'hand', null, null);
}
sendToGrave(itemKey){
	this.addFromBoardElement(player[itemKey], listPosition[itemKey], boardElement[itemKey], 'grave', null, null);
}

Some helpers like 'addShieldToHand' have other bits to them too that would trigger other functions, or effects too. Some are literally just one liners to make the code easier to understand and work with.

No more hiding what the game is?

For my code examples from now on, they will be near identical to the code at the time I merge the feature the topic is based on into my devlopment branch. Eventually (rather soon in episodic article terms) I will need to detail what the game is to convey the why, so look forwards to that.

Being said, up to then I won't directly address the genre, but won't be changing names of 'card', etc. anymore.

I hope to keep showing the progressing code-base, and would love for people to follow my development journey, and to inspire others to start a game. If you do start a game, feel free to reference my work in these articles, but try not to rip off everything (despite what I post being mostly unfinished/finalised).

Updates

29/10/24 - If you're reading this as a semi-guide, this is not the optimal way to deal with entities/components. I will be rewriting this once the core functionality of the game is written.