Components, Signals, and You: A guide to uncooking a spaghetti

How, what and why to code in BYOND.

Moderators: MisterPerson, Code Maintainers

User avatar
ninjanomnom
Code Maintainer
 
Joined: Wed May 31, 2017 3:35 am
Byond Username: Ninjanomnom
Github Username: ninjanomnom

Components, Signals, and You: A guide to uncooking a spaghetti

Postby ninjanomnom » Fri May 17, 2019 11:29 pm #494358

The datum component system (DCS) which was added in https://github.com/tgstation/tgstation/pull/29324 has been around for a while now and deserves a proper introduction and some explanation now that it's being more commonly used and expanded on. This has been a while coming, blame me for my laziness.

DCS is complicated. Not just because it uses clever solutions for complicated problems, the way you have to think about those problems changes drastically as well. Don't worry though, it's a bit of a pain to start but after the first learning period it just clicks. You see all sorts of terrible systems and code around the codebase that can be improved by extracting functionality into a more maintainable chunk. You realize features you can implement that are only viable when you can make the target of all that functionality abstract.

To name a few existing example, we have a component which handles an object making noise when hit/thrown/used/etc. You can apply this to anything, even walls if you want. A component which turns anything into a storage item. A component which handles rotating things by hand in character. A component that makes diseases spread. You get the point.

Let's get some terminology out of the way:
DCS, Datum component system: Bit of an outdated term now but this was the original name of the system as a whole
Components: Isolated holders of functionality. They hold all the data and logic necessary to accomplish some discrete behaviour.
Signals: The way components receive messages. Originally only components could receive signals but since then it has been expanded so anything can receive a signal if it's useful.

Signals and how they work
These are the primary way for components to interact with the rest of the game. They're fairly simple in concept: First you create a place the signal gets activated from. Let's have a signal that activates when someone walks over it. (I'm going to be using /datum/component/squeak for most examples in this post)
Code: Select all
#define COMSIG_MOVABLE_CROSSED "movable_crossed"

Code: Select all
/atom/movable/Crossed(atom/movable/AM, oldloc)
   SEND_SIGNAL(src, COMSIG_MOVABLE_CROSSED, AM)

Here we have the base Crossed proc set up to send a signal and do nothing else. src is given as an argument so that it is a signal with itself as the origin. Next up the signal type is given, it is defined in __DEFINES/components.dm then passed in to the signal. Functionally it's just a string acting as an identifier. The third argument in the signal is the movable that's crossing over, this gets passed to whoever is receiving the signal.

Now that we have a signal at the top of the inheritance chain, any other overrides of the Crossed proc will have to either call parent with ..() or send the same signal like we did here in order to make sure that signal is called whenever the proc is called. Avoid making duplicate signals as much as possible. Signals should have a single source of origin so it is as easy as possible to reason about behavior when receiving those signals. If you feel the need to make additional signal sources you should consider instead just making additional types of signals. More types of signals is free.

Now that this new signal exists we can make something able to listen for the signal.
Code: Select all
/datum/component/squeak/Initialize()
   [...]
   RegisterSignal(parent, COMSIG_MOVABLE_CROSSED, .proc/play_squeak_crossed)

Code: Select all
/datum/component/squeak/proc/play_squeak_crossed(datum/source, atom/movable/AM)
   [dostuff]


Here, when the object, a component in this case, is created, it registers for the signal. It is now "listening" for that signal and any time that signal is sent the component will know about it. Any arguments given in that signal get passed along to the listener. This then calls the proc specified when registering for the signal. You can make any proc get called this way, check out callbacks to see how that works.

Where did that datum/source come from? Signals will in addition to their normal arguments pass the originator of the signal as the first argument every time. This is useful when you have something listening for the same signal on multiple other objects.

You now have a working component receiving a signal from the object it's applied to, but what's a component?

Sealing away the bad code with components
Components are the original reason signals were added. Signals may have moved on to bigger things but components are still a very powerful tool for having complex functionality that is easy to maintain. Components are only in charge of themselves, they have, in general, simple responses to simple events. They don't care what else their owner is doing or how it's doing it. If the squeak component receives a signal saying that someone has walked on it's owner then by god it's going to play a sound.

As previously mentioned the primary way components interact with the world is through signals. There are a couple other ways though that I'll go over fast.

The component has a reference to the owner of that component. This is most commonly used to get some state information like the location of the owner to know where to play a sound etc.

The other way you can interact with components is through the GetComponent proc. This proc is plain and simple a crutch, what it does is allow you to get a given type of component that's been applied to an object. This method of interacting with a component hurts the entire usefulness of them. If you have a component that depends on external code to do functionality, you're moving back once more to spaghetti code. You cant know if you see all the possible paths logic can take just by looking at the component.

This bears repetition: The GetComponent proc is a crutch and hurts your component. Avoid it if at all possible.

Take a look at the following code. It is a component which poorly implements a way to change the color of a bullet
Code: Select all
/type/path/bullet/proc/set_color(newColor)
   var/datum/component/color/comp = GetComponent(/datum/component/color)
   if(!comp)
      color = newColor
   else
      color = comp.newColor


Note the component isn't actually doing anything. It is not compartmentalizing anything. We're just using a component to hold a value for the sake of saying we're using a component.

There are many ways to do this properly, all of them involve signals:
Code: Select all
/type/path/bullet/proc/set_color(newColor)
   if(SEND_SIGNAL(src, COMSIG_BULLET_SETCOLOR, newColor) & COMPONENT_CANCEL_SETCOLOR)
      return
   color = newColor

Code: Select all
/datum/component/color/Initialize()
   RegisterSignal(parent, COMSIG_BULLET_SETCOLOR, .proc/set_color_react)

/datum/component/color/proc/set_color_react(type/path/bullet/source, newColor)
   source.color = newColor + red // It's pseudocode, go away
   return COMPONENT_CANCEL_SETCOLOR


First off we've removed the GetComponent, no longer is this object depending on that component existing. Everything in the bullet is generic and the component handles exceptional cases. Further, now that the functionality is isolated it would be very easy to instead change this to be able to modify color on any atom in the game, just move where the signal is and make sure it gets called and obeyed properly. There is a problem here with doing that, everything that overrides the proc will need to make sure as well that it has been canceled. A second solution to this problem makes it possible to fix that:

Code: Select all
/type/path/bullet/proc/set_color(newColor)
   SEND_SIGNAL(src, COMSIG_BULLET_SETCOLOR, args)
   color = newColor

Code: Select all
/datum/component/color/Initialize()
   RegisterSignal(parent, COMSIG_BULLET_SETCOLOR, .proc/set_color_react)

/datum/component/color/proc/set_color_react(type/path/bullet/source, list/arguments)
   arguments[1] += red // It's pseudocode, go away


You're probably wondering how this works; unless you're more experienced with byond and/or programming in general this is magic. Lists are passed as references, meaning if we modify it in one place, anywhere else that has that same list will also be modified. args is a special list in byond that is all the arguments of a proc, if we pass that list into a component, it is able to then modify that list which modifies the args of the proc that called it and all potential children of that proc. This is incredibly powerful. However you pay for it with a small loss in readability. Whether the situation calls for it is up to your discretion.

Keep the code for a particular feature as isolated in their respective components and you'll end up with a more maintainable, more extendable, and more bug resistant system. The costs in performance are minimal for all this and in many cases cheaper on the cpu. Memory wise there are potentially issues in extreme examples but there are plans in the works to make that less so.

TL;DR Components good. Signals good. Old code bad.
Narcissistic stuff others said/made for me
Spoiler:
Image
Image



User avatar
ninjanomnom
Code Maintainer
 
Joined: Wed May 31, 2017 3:35 am
Byond Username: Ninjanomnom
Github Username: ninjanomnom

Re: Components, Signals, and You: A guide to uncooking a spaghetti

Postby ninjanomnom » Fri May 17, 2019 11:29 pm #494359

FAQ here I suppose?

I'll update the op with more if it seems necessary which it probably will be. I just got some motivation and spat this out based on previous conversations explaining these concepts to people.
Narcissistic stuff others said/made for me
Spoiler:
Image
Image


Return to Coding

Who is online

Users browsing this forum: No registered users