-
Notifications
You must be signed in to change notification settings - Fork 8
Component Best Practices
This document describes some of the best practices to bear in mind when writing systems or designing components.
A common mistake when you first start writing components is to think in terms of the "object on the screen", and make a component and a system named after it, which simulates the entirety of the behaviour of that object.
A simple way to avoid this mistake is to think of systems as performing an action (thus, verbs), and components as adding properties and data to an entity (thus nouns, or in some cases adjectives). Sometimes there is a one-to-one mapping between components and systems, but often there isn't. Systems may use multiple components to achieve their purpose, and a single component is likely to be used by multiple systems. For this reason, there is no need to give systems and components the same name, and in fact in some cases if is detrimental as it discourages the flexible reuse the component system is intended to provide.
An example of this principle in action is the extremely simple
MeasureDistance
system. Here the verb is "measure": it calculates the
distance travelled over the course of a frame from the data in the
PhysicsComponent
, and adds it to the DistanceTravelled
component.
DistanceTravelled
is then used by a number of other systems which base their
behaviour on the total distance travelled by an entity (which may be a
projectile, a car, or whatever) over the course of its lifetime.
It is definitely possible to waste time thinking of names for things, but a little bit of consideration can help guide you to a more flexible, reusable design.
This is closely related to the point above about naming. A single system
should have a single, clearly-defined responsibility. It should use the
minimum number of components required to achieve its task, preferably with zero
or few Optional
components hiding parts of its runtime in an
if(....Exists())
clause.
The so-called single responsibility principle is common to all programming
paradigms. Classes in object-oriented code, functions and typeclasses in
functional code, and systems in component-oriented code should all be designed
with this in mind. It is particularly important for systems, however, because
their execution is based on the presence or absence of their Required
components. The more bloated a system gets, the more components it requires,
and the more fragile it becomes in the face of components being added or
removed from a certain class of entities.
What do I mean by "fragile"? Well, one of the weaknesses of our
component-oriented design is as follows: you have a system with many required
components and a number of responsibilities, running on many kinds of entities
in the world -- say cars, missiles, and planes for example. You get a bug
report describing a problem in the behaviour of cars in the game, and you track
it down to this system. The solution is to add another required component which
you know all the cars have, and make use of it in the Run()
function to fix the
bug. You make the change, test it, and it works, so you mark the bug "fixed". Little do you
realise that planes, which don't even exist in the level you were testing,
don't have this component, and so you have unwittingly disabled the system for
all planes in the game!
Desigining systems such that they have a single responsibility and act on few components can help mitigate this problem by reducing the surface area impact of entities affected by a particular system.
Even when the behaviour required is complex, it is usually best to achieve it by composing together multiple simple systems rather than cramming tonnes of functionality into one big system that does everything.
This is probably the longest-standing tip in this document, but it still
happens occasionally so it bears repeating: GetComponent()
is potentially
slow, increases code fragility, and breaks in multi-threaded environments. We
are hoping to refactor the component system so that it is impossible to call
this function within a signal, and possibly to make declared inputs and outputs
the only way to get access to components. Use of GetComponent()
in new code
being written today takes us further away from that goal and prevents us from
making significant improvements to the component system as a whole, and it
should be avoided at all costs.
In fairness to some of the current uses of GetComponent()
in the codebase,
there was a time, before we had the hierarchy and events systems we have now,
where it was unavoidable. That time is long past, and introducing new calls to
GetComponent()
in signals code at this stage is unacceptable.
Sometimes, when writing a system, you may be tempted to add a component which has an array of some data or resource which you will iterate over in the system code. This is often a sign of the design being "the wrong way round", so if you notice this it's an opportunity to rethink your design and consider whether there's another approach which would take better advantage of the facilities the component system provides.
The component system is, at its core, a sort of iteration engine. You set up a collection of self-contained entities in a hierarchy, and the component system will handle iterating over them for you, allowing you to concentrate on the actual functionality of those entities. As we improve the component system and add support for multithreading, your entities will take advantage of those improvements automatically -- so long as you leave the mechanics of the iteration up to the framework.