This tutorial goes over the entity-component system and several other key topics in the SS14 codebase by demonstrating how one would implement a clown horn from scratch. You can try copying the steps yourselves, or you can just read along.
Before we get into the tutorial, let's briefly touch over the foundation of the SS14 codebase. In SS14, we organize code by a paradigm called ECS: Entity-Component-System. You might be familiar with OOP (Object-Oriented Programming), which virtually every game in existence uses. ECS is programmed with OOP, but often deviates or otherwise doesn't make use of its principles. Let's discover what that means.
ECS means that in our world, objects are entities. The player is an entity. A banana is an entity. A stun baton is an entity.
An entity by itself is, however, barebones. The EntityUid
struct only holds its numeric identifier. It won't even have a sprite! In order to give it behavior, we need two things: components, then systems that act on those components.
Components hold data and nothing else. An entity can have as many components as you'd like, but only one of each type of component. For example, a banana might have a component named FoodComponent
, which contains a decimal value called nutritionLeft
. However, remember that components shouldn't do anything with the data they hold - that's what systems are for.
Systems (in SS14, EntitySystems) contain logic, or the code that gives components behavior. Unlike components, only a single system exists throughout the entire game - this system is expected to manage all components of a certain type (or types). To achieve this end, EntitySystems are capable of listening to events and executing code whenever said event occurs.
Back to our FoodComponent
example, a programmer might make EatingSystem
to handle eating food. EatingSystem
listens to the OnUseInHand
event - whenever OnUseInHand
is heard/triggered, EatingSystem
checks if there is a FoodComponent
in the object that was used. If there is, then it lowers the value of nutritionLeft
and plays a munching sound.
That's the jist of ECS. If you're interested in learning more about it, then check out Your mind on ECS. The ECS approach really is powerful and allows us to avoid spaghetti code, despite the complexity of SS14.
You don't have to perfectly understand the ECS architecture at first. It can be daunting for both new programmers and those used to traditional OOP. However, the overall 'feel' and advantages of the architecture should become clear as you use it more.
SS14 uses a system we call prototypes. These are "entity presets", essentially. They are similar to prefabs in Unity, or a subtype of /obj
or /mob
in BYOND.
Entity prototypes define which components are on the entity, and what data those components hold. It also defines basic data like the entity's name, description, and prototype ID (used to spawn it).
An example is shown below:
- type: entity
parent: BaseItem
id: Skub
name: skub
description: Skub is the fifth Chaos God.
components:
- type: Sprite
sprite: Objects/Misc/skub.rsi
state: icon
- type: Item
sprite: Objects/Misc/skub.rsi
- type: ItemCooldown
- type: EmitSoundOnUse
sound: /Audio/Items/skub.ogg
- type: UseDelay
delay: 2.0
This is written in YAML, a data language similar to JSON, and is located in the folder Resources/Prototypes/Entities/Objects/Fun/skub.yml
. All prototypes must be in the Resources/Prototypes
folder and should be organized into the proper folder.
If you want more pointers on YAML, check YAML Crash Course and Serialization.
The entity prototype shown is "Skub", which looks like this in game:
As you can see in the YAML, it has many components, including EmitSoundOnUse
and ItemCooldown
. It is up to the coders to determine what data components hold and how systems give them behavior.
To spawn the items in game from a prototype, you can press F5 to open the Entity Spawn Panel. There is also a way to spawn prototypes in code.
Your goal is to make a Clown Horn that honks when you use it. This requires us to have a component on the entity with a sound to play and system that plays that sound after it's used in hand (clicked, or activated with Z).
Normally, you would want to search through the codebase and ask some other coders to see whether a component/system that does this already exists. In this case,
EmitSoundOnUse
does indeed exist in the main SS14 codebase. But for the sake of this tutorial, we'll pretend it doesn't and try to implement it ourselves!
To start off, let's make a simple clown horn prototype. I will make a new file called clown_horn.yml
and add it to the Resources\Prototypes\Entities\Objects
folder.
Might want to organize that into the "Fun" folder later, but organization is up to you and your codebase!
Now let's fill out the prototype with a basic clown horn. Because we don't yet have a dedicated SS14 prototype editor, many people usually just copy a similar prototype and modify it to their needs.
- type: entity
name: clown horn
parent: BaseItem
id: ClownHorn
description: It goes honk honk!
components:
- type: Sprite
sprite: Objects/Fun/bikehorn.rsi
state: icon
Here we have a basic entity with a single component: SpriteComponent
. Check out the RSI spec if you're unfamiliar with the RSI system, but the gist is that we have two fields for SpriteComponent
: the RSI path relative to Resources/Textures
(in this case the folder is named bikehorn.rsi) and the icon state.
One thing to note is that prototypes support parenting. In this case, BaseItem
is our parent and contains a variety of components that are universal to all items. Thus, our clown horn will have those components too: basic components like Item
, Pullable
, and Physics
. Parents aren't required at all, but they're useful in certain cases, like here.
Now, let's compile and check out our item in game:
It sure is beautiful, but we appear to have lied! The bike horn does not yet honk honk. To remedy this, we'll have to create a new component to hold the data, such as the sound to play, and an EntitySystem which handles actually playing the sound.
To make our component, we'll need to make a new class, let's call it PlaySoundOnUseComponent
. But wait a second....
Where do we put it? To answer this question, we have to think broad. We have to think about the client and the server.
If you haven't read Module & Hierarchy Overview already, it might be worth a read. But for this tutorial, there are only two things you need to understand:
With that in mind, our logic for our clown horn should look like this:
This sounds rather complicated to implement from scratch. Thankfully, we have some premade code that helps us! Namely, the event UseInHandEvent
which is raised on the server when an item is used, and the function SoundSystem.Play()
which plays a sound to clients in range.
Those helpers can be thought of as handling client click -> server and server -> client sound for us. so all we need to do is have a component on the server which routes one into the other.
In the Space Station 14 codebase, Components & EntitySystems alike (along with other classes) go inside folders directly under the
Content.Server
,Content.Shared
, orContent.Client
projects. There are folders forAtmos
,Botany
,Research
,Storage
, and a lot more. If a suitable folder doesn't exist, create one! Never put files directly into the top directory of the project.
Under the Content.Server
project, there's a folder called Sound
. That seems like a good place to put our new component (and in fact, this is where the real EmitSoundOnUse is located). Let's call our version PlaySoundOnUseComponent
. Note: if you just copy paste this code in, it may not work, as you'll need to import various classes. Your IDE can do this for you.
Now let's just make the most basic component possible:
// Content.Server/Sound/PlaySoundOnUseComponent.cs
namespace Content.Server.Sound
{
[RegisterComponent]
public class PlaySoundOnUseComponent : Component
{
}
}
All components must inherit from the Component
class. If you want your component to be read in YAML, you'll have to add [RegisterComponent]
above your class.
In our prototype above, you might recall that we added Sprite
, not SpriteComponent
to the ClownHorn prototype. That's because component 'names' are autogenerated using the class name. In this case, our component's name is PlaySoundOnUse
, which is generated by just removing Component
from the class name.
Now, let's go ahead and add PlaySoundOnUse to our prototype.
- type: entity
name: clown horn
parent: BaseItem
id: ClownHorn
description: It goes honk honk!
components:
- type: Sprite
sprite: Objects/Fun/bikehorn.rsi
state: icon
- type: PlaySoundOnUse
Well, this is boring; not only does our component not have any data, but it doesn't do anything either!
Let's add some data to our component. As you may have noticed above, the Sprite
component on our bike horn has two fields listed: sprite
, and state
. Whatever you put in these fields will be passed into the component when it's created, and then our EntitySystem can use that data to do something.
In our case, we'll probably want a field called sound
on our component, which stores a path to the sound to play when the entity is activated. It's pretty easy to do that:
// Content.Server/Sound/PlaySoundOnUseComponent.cs
namespace Content.Server.Sound
{
[RegisterComponent]
public class PlaySoundOnUseComponent : Component
{
[DataField("sound")]
public string Sound = string.Empty;
}
}
All you need to do to create a field that can be modified in YAML is to add the [DataField]
attribute, which holds the name of the field, and give it a default value, in this case string.Empty
. Now, we can add our sound to our bike horn prototype:
- type: entity
name: clown horn
parent: BaseItem
id: ClownHorn
description: It goes honk honk!
components:
- type: Sprite
sprite: Objects/Fun/bikehorn.rsi
state: icon
- type: PlaySoundOnUse
sound: /Audio/Items/bikehorn.ogg
Now we're getting somewhere! One thing to note is that the path here is relative to the Resources
directory (which SoundSystem
always assumes), and we're also assuming that the Resources/Audio/Items/bikehorn.ogg
file is real. If you check, it is! But if a sound isn't present that you need, you can always add it yourself somewhere in the Audio
folder.
Let's finally add some flavor to our bike horn by.. making it actually honk. As said previously, we'll need an EntitySystem
which hooks into the UseInHandEvent
and calls some code from there. Let's create our EntitySystem PlaySoundOnUseSystem
in the same Content.Server/Sound
folder:
// Content.Server/Sound/PlaySoundOnUseSystem.cs
namespace Content.Server.Sound;
public class PlaySoundOnUseSystem : EntitySystem
{
}
You'll notice that here, our system inherits from EntitySystem
. This automatically registers it as a proper EntitySystem in the game and allows us to use some useful dependencies and override some methods to add behavior.
In order to subscribe to an event being raised, we'll need to override the system's Initialize
method; this method is called when the EntitySystem is created.
In this method, we'll add a SubscribeLocalEvent
call, and I'll explain the details after the fact.
// Content.Server/Sound/PlaySoundOnUseSystem.cs
namespace Content.Server.Sound;
public class PlaySoundOnUseSystem : EntitySystem
{
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<PlaySoundOnUseComponent, UseInHandEvent>(OnUseInHand);
}
}
There's a lot going on in that method call! Basically, we're telling the game:
"Whenever a UseInHandEvent is raised on an entity that has the PlaySoundOnUse component, I want you to call my OnUseInHand method."
You've probably noticed that this code actually gives you an error, because the method OnUseInHand doesn't exist yet! Let's add that method. This is called an event handler, and event handlers require a specific set of arguments:
If you're using an IDE, it might allow you to automatically create this method using Alt+Enter.
Here's what our class will look like now, with our new method:
namespace Content.Server.Sound;
public class PlaySoundOnUseSystem : EntitySystem
{
[Dependency] private readonly SharedAudioSystem _audio = default!;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<PlaySoundOnUseComponent, UseInHandEvent>(OnUseInHand);
}
private void OnUseInHand(EntityUid uid, PlaySoundOnUseComponent component, UseInHandEvent args)
{
}
}
We're almost there. Now, the method OnUseInHand
will be called when we activate the item, and we can play our sound there.
Also, we've added [Dependency] private readonly SharedAudioSystem
to class. It will allow us to play audio in modern way (instead of using obsolete SoundSystem.Play
) further.
private void OnUseInHand(EntityUid uid, PlaySoundOnUseComponent component, UseInHandEvent args)
{
_audio.PlayPvs(component.Sound, uid);
}
The PlayPvs
method is useful for playing sounds. It has two arguments:
In this case, we just pass it our sound
field on our PlaySoundOnUseComponent
.
This is an optional argument that is used for positional audio. In our case, we want the sound to come from the horn, so we pass in the horn's Uid. If this arugment is not given, the sound is played globally and will be audible to all players.
If you compile the game and spawn our bike horn using the F5 Entity Spawn Menu, you can try activating it in hand and--incredible! It plays the sound properly! Hopefully! If not, you might have messed something up in the YAML, or missed a method in the EntitySystem.
Also, PlayPvs
automaticly manages distance filtering, so you don't have to worry about it.
With that, this tutorial is finished! If you want to continue experimenting with your newfound clown horn, here are some ideas:
ItemCooldown
to your prototype, and raising the RefreshItemCooldownEvent
.PlayPvs()
function's audioParams
argumernt).The world's your donk packet, and you've got a sizzling hot fire ready to cook it!