MaxIsJoe's website
.

How I added area/room detection in Unitystation

Published on Feb 9, 2024

Game DevUnitystationC#
Go Back

One of Space Station 13’s most memorable traits is its sound design. Despite it being mostly composed of heavily edited royalty free sounds, SS13 builds a strong impression on the player with its stellar sound effects and mood-setting ambience. As a remake of SS13, Unitystation is set to also carry over that trait as well.

When walking around on unitystation, you should be able to hear ambient clips that define the area that you’ve just walked in, just like in SS13. When entering the chapel, you should be able to hear the ambient choirs of the holy department you’ve just entered. The buzzing of electrical wires and the rumbles of the space station when walking inside the maintenance tunnels. The melodic sounds of gasses swimming through pipes that supply the station with the breathable air.

Before February 7th, 2024. Unitystation did not have the ability for expressing the tone and purpose of each area the player has walked through via sound, due to us not having a way to easily identify what each area was to start with.

SS13 used BYOND’s native Area functionality to easily define what each part of the map meant to various other systems. It is a neat feature in BYOND that lets you do things like defining zones where PvP isn’t allowed or designing a classic JRPG random encounter system, or in SS13’s case, add the ability to play ambient clips/tracks for the specific kind of area that the player is walking in.

While Unity, the engine Unitystation uses, does not have any sort of functionality like this, We’ve decided to not implement a system like that into unitystation. We’ve instead opted to creating a re-usable system to automatically gauges what kind of area the player is in using various defining features around the player, such as what type of items/objects surround them and what type of tile they’re standing on. This system is useful as it adapts to the changes made by players during the round without the need of mappers.

Where it all started

Before I start explaining how the new system works, I should probably go over some history that made us decide to go with system instead of SS13’s traditional areas feature.

Sometime in 2023, I’ve actually wanted to implement Areas for tilemaps on unitystation to be able to add in ash storms for Lava Land to unitystation. When I first announced that I’m going to work on it, there was a lot of opinions on how feasible Areas are for our remake. A lot of these opinions were valid criticisms on how BYOND area works, and since unitystation is meant to be a remake of SS13 that throws out all the older limitations and odd design choices the game once had; a lot of people were understandably not a big fan of porting such an outdated concept that doesn’t fit within Unitystation’s vision of the modernization of SS13.

The main issue everyone had with Areas is how static it felt compared to the rest of the game. We’ve spent an entire day trying to redesign Areas in various different ways to be more dynamic, as well as expanding the scope of how players can manipulate tiles so it isn’t just defined by mappers, but in the end, we’ve created a giant mess of ideas and complex systems that required a lot of math and tons of moving parts that simply left everyone dissatisfied with the idea of ever adding Areas to unitystation.

To give you an idea of how much of the mess the system we’ve discussed was, we’ve gone from:

  • Simply manually defining what area does a tile belong to

To:

  • Adding fuzziness values to define what “types” of areas a tile is in.
  • Making a pillar system to define when changes have happened in an area.
  • Calculating the center of an area object using the fuzziness values.
  • Shrinking/Enlarging the bounds of an area while keeping the object count low so we don’t accidentally generate thousands of objects to keep track of areas.
  • Figuring out when is the appropriate time to update areas around the station.

And a lot more bad ideas that have been thankfully scrubbed from my weak memory.

The discussions ended with us deciding that Areas were not good enough for unitystation, and that we needed a way to dynamically detect what type of room or area the player is in using all the nearby objects around them. At the time, that wasn’t feasible as it raised a lot of performance concerns due to Unity not having a way to reliably search up components without generating a lot of garbage for the garbage collector (GC) to worry about. So we mostly forgot about this for a couple of months, until I came up with a better solution to counter this.

A Better Solution

My philosophy when working on Unitystation is that every solution must not be complicated for any future maintainer to maintain/understand, and that the solution must be a modular one, so that can be re-used across various different ideas inside the project.

Thus, The ComponentsTracker was born.

While working on the Faiths system, I’ve needed a way to find different kinds of components around several players at the same time. The problem with that at the time was that functions like GetComponents<T>, Linecast and physics related checks were incredibly costly performance wise; which resulted in poor performance when attempting to find objects on the server’s side. Even if we Timesplit the workload across various different frames, there is still the huge garbage collector spike that we had to worry about due to how poorly optimized some of Unity’s functions are.

I needed a way to keep track of all components that I needed during the game’s runtime without spiking GC, so I’ve came up with ComponentsTracker; A system that registers components in a Hashset during their lifetime. Sounds simple, right?

With this general system, we could bypass GetComponent<> and its many cons completely, and just directly ask the ComponentsTracker to give us the components that we need.

For example, if we want to get all Light Sources in the game, we just go ComponentsTracker<LightSource>.Instances and it will return them instantly. None of them will be null as well.

or, if you want to get all nearby food to a player, you just go ComponentsTracker<Edible>.GetAllNearbyTypesToTarget(target: player, searchRadius: 6, bypassInventories: true)

Searching for components inside the ComponentsTracker script is so fast, it can loop over 55k components in less than 15 milliseconds! And that’s inside the unity editor, on built clients this is even faster.

With such a fast way of searching through tracked components being available to us, I’ve decided to showcase how truly powerful this new solution I’ve made is by tackling areas/room detection again.

Hurdles

During my time on unitystation, many people might have noticed that I have this sort of addiction to refactoring and reworking any kind of system that I get my hands on. This is for a good reason.

The Unitystation codebase for the longest of time has been mostly held by duct tape, hopes and dreams, and the esoteric knowledge of one british orange fox. Thankfully, most of our code nowadays is somewhat thought out, and aren’t as needlessly complicated as they were back then.

However, every now and then, someone will come across a few remnants of code written by a drunk aussie who was probably too busy fighting spiders the size of a human torso to think about the general consequences of his rushed code. Unfortunately, I’m that someone.

While trying to work on the new dynamic ambience system, I wanted to track the Attributes component to check what traits each object and item has. Problem? Attributes does not have traits, ItemAttributesV2 has, which is inherited from Attributes.

“Okay? You could just move over the List<ItemTrait> initialTraits property to the Attributes class. What is the issue?” The issue is Unity.

Despite ItemAttributesV2 inheriting from Attributes, Unity thinks it needs to generate a new field completely for ItemAttributesV2 when moving initialTraits over to Attributes. This breaks all items in the game briefly.

Solution? Write a script that opens all prefabs with the ItemAttributesV2 component, add some junk data, save them, remove the junk data, save them again. Fixed.

How did I find out about this? By hitting my head against the keyboard hard enough in frustration until I randomly stumbled upon this solution while thinking of another solution to manually edit the prefab’s text data to move all the old serialized property onto the new one.

This is one of the many instances of me having trouble with even the simplest refactors on Unitystation. Reminder, this refactor was just me moving a single property around.

This wouldn’t have been an issue if Attributes was the only script that existed, and we did not separate their functionality into ItemAttributesV2 and ObjectAttributes. There is a reason for why I updated our development standards guide to specifically design things around compositions instead of inheritance, because we end up with stupid issues like this.

How The Dynamic Ambience System Works

Now that we’ve talked about all what led to this moment, It is time to actually explain how the system works.

The entire ambience system is just two scripts.

namespace Systems.DynamicAmbience
{
	public class DynamicAmbientSounds : MonoBehaviour
	{
		public PlayerScript root;
		public List<AmbientClipsConfigSO> ambinceConfigs = new List<AmbientClipsConfigSO>();
		public float timeBetweenAmbience = 135f;

		private void Start()
		{
			if (CustomNetworkManager.IsHeadless) return;
			UpdateManager.Add(CheckForAmbienceToPlay, timeBetweenAmbience);
		}

		private void OnDestroy()
		{
			if (CustomNetworkManager.IsHeadless) return;
			UpdateManager.Remove(CallbackType.PERIODIC_UPDATE, CheckForAmbienceToPlay);
		}

		private void CheckForAmbienceToPlay()
		{
			if (root.Mind.NonImportantMind || root.isOwned == false) return;
			var traitsNearby = ComponentsTracker<Attributes>.GetNearbyTraits(root.gameObject, 6f, false);
			var configsToPlay = new List<AmbientClipsConfigSO>();
			AmbientClipsConfigSO highestPriority = null;
			foreach (var config in ambinceConfigs)
			{
				if (config.CanTrigger(traitsNearby, root.gameObject) == false) continue;
				configsToPlay.Add(config);
				if (highestPriority is null)
				{
					highestPriority = config;
					continue;
				}
				if (config.priority > highestPriority.priority) highestPriority = config;
			}
			var configChoosen = DMMath.Prob(80) && highestPriority is not null ? highestPriority : configsToPlay.PickRandom();
			configChoosen.OrNull()?.PlayRandomClipLocally();
		}
	}
}

and

namespace Systems.DynamicAmbience
{
	[CreateAssetMenu(fileName = "AmbientClipsConfig", menuName = "ScriptableObjects/Audio/AmbientClipsConfig")]
	public class AmbientClipsConfigSO : ScriptableObject
	{
		public List<ItemTrait> triggerTraits = new List<ItemTrait>();
		public List<AddressableAudioSource> ambientClips = new List<AddressableAudioSource>();
		public List<BasicTile> requiredTiles = new List<BasicTile>();
		public bool needsUnderFloorsNotCovered = false;
		public bool onlyWorksOnMainStation = false;
		public bool onlyUsesTileChecks = false;
		public int priority = 0;

		public bool CanTrigger(List<ItemTrait> nearbyTraits, GameObject player)
		{
			if (onlyUsesTileChecks && TileChecks(player)) return true;
			return triggerTraits.Any(nearbyTraits.Contains) && TileChecks(player);
		}

		private bool TileChecks(GameObject player)
		{
			var registerTile = player.RegisterTile();
			if (onlyWorksOnMainStation && registerTile.Matrix.IsMainStation == false) return false;
			if (needsUnderFloorsNotCovered && registerTile.IsUnderFloor() == false) return false;
			if (requiredTiles.Count == 0) return true;
			var tile = player.RegisterTile().GetCurrentStandingTile();
			return tile != null && requiredTiles.Contains(tile);
		}

		public void PlayRandomClipLocally()
		{
			_ = SoundManager.Play(ambientClips.PickRandom(), new Guid().ToString());
		}
	}
}

It is literally quite that simple.

Each type of Area or Room is a scriptable object that defines all the requirements for it to be detectable.

Every 2.25 minutes, the game will ask the ComponentsTracker to retrieve all nearby attributes to the player (this operation only takes a maximum of 6 milliseconds in the editor!). Then it will pass a list of traits from the retrieved attributes to all configs to ask them if the player is in an area with the config’s requirements.

public static List<ItemTrait> GetNearbyTraits(GameObject target, float searchRadius, bool bypassInventories = true)
{
	var items = ComponentsTracker<Attributes>.GetAllNearbyTypesToTarget(target, searchRadius, bypassInventories);
	var traits = new List<ItemTrait>();
	foreach (var item in items)
	{
		traits.AddRange(item.InitialTraits);
	}
	return traits.Distinct().ToList();
}

GetNearbyTraits() is just a quick way to use ComponentsTracker<Attributes>.GetAllNearbyTypesToTarget() without rewriting the same code over and over again for getting a list of ItemTraits.

Each config has to check for two things, the type of tiles the player is standing on, and the nearby traits.

If the config’s requirements are met, it will be added to a list of potential configs to be chosen from for the game to use to play ambient clips.

All of this combined generates a very tiny GC footprint that is pretty negligible, and will never cause any noticeable hiccups or stutters during actual gameplay on built clients. There is probably room for improvement, but for now; this runs perfectly while keeping the code elegant and simple enough for everyone else to re-use and maintain.

But this isn’t the interesting part on how the dynamic ambience system works, the interesting part is in the ComponentsTracker works.

When an Attribute component reaches the Awake cycle inside unity, it automatically registers itself to the Instances property of ComponentsTracker<Attribute>. Instances is a static property of type HashSet<T>. Being static means that it belongs to the class itself, rather than to any specific instance of the class. Since it is a generic class, the T represents the type parameter that is specified when using the ComponentsTracker<T> class. This means that each different type used for T will have its own separate Instances property.

You might think that in the background, C# is using GetHashCode() for Instances to check which correct property to use; but in reality, each instance of Instances are defined during compile time for each type. Which makes this super fast, as the game knows which HashSet it should access immediately when working with them.

The only con of this, is that you cannot define new types during runtime. This causes our Fast Reload plugin to break in unity when attempting to track a new type that hasn’t been registered before entering play mode.

As for how GetAllNearbyTypesToTargetWorks(), it continues following the simplicity of everything we’ve talked about so far.

List<T> components = new List<T>();
foreach (var stationObject in Instances)
{
	var obj = stationObject as Component;
	if (obj == null || obj.gameObject == null || obj.gameObject.OrNull() == null) continue;
	if (bypassInventories == false && obj.gameObject.IsAtHiddenPos())
	{
		continue;
	}
	if (Vector3.Distance(obj.gameObject.AssumedWorldPosServer(), target.AssumedWorldPosServer()) > maximumDistance)
	{
		continue;
	}
	else
	{
		components.Add(stationObject);
	}
}

We have to first cast all types into a useable component, as the static function only sees them as a generic T for the time being. This isn’t the case apparently in .NET 6+ and it seems to be a Mono quirk as far as I know.

After we’ve casted it to a Unity Component, we then have to check for two conditions.

The first condition is related to checking if the object is in an inventory space, like a locker or a backpack. In unitystation, whenever something gets added into an inventory, that object is physically moved to what we call HiddenPos, which is usually 0,0 in world coordinates. We have to do this check for some cases, so we don’t accidentally create a breed of bugs that eat the player’s organs when searching for nearby Edible components to consume.

The second condition is related to checking if the component is nearby the target or not. We use Vector3.Distance() due to how incredibly faster than using LineCast to determine the distance between two objects, as well as the fact that it doesn’t generate any troublesome garbage during the process.

After the conditions are met, we add the components to the list of components that we’re about to return for use.

And that’s it. It’s that simple.

Conclusion

  • Always keep your ideas open to discussion with different people, having your ideas challenged might seem uncomfortable at first; but they will lead you to better ideas in the long run that even you will be proud of.
  • The most powerful solutions are always the simplest ones.
  • The new dynamic ambience system is able to detect the types of rooms/areas based on different traits stored on objects and items nearby the player.
  • Unity as an engine fucking sucks major balls
  • Play unitystation.