Lightweight Component Proposal
Background #
Cardinal Components is a very good API. I use it in pretty much every mod I make. However, there is a lot of boilerplate involved. Just to make a component, you must:
- Create a class
- Write NBT serialisation methods
- Write Byte Buffer serialisation methods (technically optional, but 90% of the time you want syncing, and you don't want to be throwing huge amounts of NBT over the wire)
- Register the component key
- Register the component in whichever registration event
- Register the component (again !) in your
fabric.mod.json
Each of these steps has a purpose - each facilitates a certain use-case. It lets you do all sorts of things, like having different implementations of components for different providers, or sync different data to different clients, or do incremental syncing. I often do these things.
The problem #
Most of the time, though, I don't do those things. A very large amount of code I write these days follows this formula:
First, we have an interface which we'll call Thing
. It doesn't matter what this 'Thing' is, all that matters is that
somewhere we have a registry of Thing
s, and that a Thing
doesn't hold any state by itself. In this way it's very
similar to Block
or Item
(but not Entity
):
interface Thing {
void onTick(ServerPlayer player);
void onPlayerTryDoThing(ServerLevel level, BlockPos pos, ServerPlayer player);
}
Then, we have some implementations of Thing
:
class AThingWhichKillsYouIfYouTryAndJumpAfterTryingToDoSomethingTooManyTimes implements Thing {
@Override
void onTick(ServerPlayer player) {
// since we don't hold state inside our Thing,
// we instead hold state in a component.
var myComponent = player.getComponent(AttemptsComponent.KEY);
if (player.jumping && myComponent.timesTriedToDoThing > 3) {
player.kill();
}
}
@Override
void onPlayerTryDoThing(ServerLevel level, BlockPos pos, ServerPlayer player) {
var myComponent = player.getComponent(AttemptsComponent.KEY);
myComponent.incrementHowManyTimesHasTriedToDoThing();
return false;
}
}
but since we used that component, we then need to set it up and register it!! Here's another file:
class AttemptsComponent implements Component, AutoSyncedComponent {
public static final ComponentKey<AttemptsComponent> KEY = ComponentRegistry.getOrCreate(id("attempts"), AttemptsComponent.class);
private final Player owner;
private int timesTriedToDoThing;
public AttemptsComponent(Player owner) {
this.owner = owner;
}
public void incrementHowManyTimesHasTriedToDoThing() {
this.timesTriedToDoThing++;
KEY.sync(this.owner);
}
}
And this is in yet another file, in our mod initialiser:
public void registerEntityComponentFactories(@NotNull EntityComponentFactoryRegistry registry) {
registry.registerForPlayers(AttemptsComponent.KEY, AttemptsComponent::new, RespawnCopyStrategy.ALWAYS_COPY);
}
And then, in our fabric.mod.json
, we'll want to add:
{
"custom": {
"cardinal-components": [
"mymod:attempts"
]
}
}
That's a lot of boilerplate!!
The alternative #
Wouldn't it be nicer if instead we could do something like this, with no further component setup:
class AThingWhichKillsYouIfYouTryAndJumpAfterTryingToDoSomethingTooManyTimes implements Thing {
@Override
void onTick(ServerPlayer player) {
// since we don't hold state inside our Thing,
// we instead hold state in a component.
var myComponent = player.getComponent(AttemptsComponent.class);
if (player.jumping && myComponent.timesTriedToDoThing() > 3) {
player.kill();
}
}
@Override
void onPlayerTryDoThing(ServerLevel level, BlockPos pos, ServerPlayer player) {
player.updateComponent(AttemptsComponent.class,
c -> new AttemptsComponent(c.timesTriedToDoThing + 1));
return false;
}
@LightweightComponent(persist = true,
syncMode = SyncMode.ONLY_TO_OWNER,
attachTo = {AttachTo.PLAYERS},
respawnCopyStrategy = RespawnCopyStrategy.ALWAYS_COPY)
record AttemptsComponent(int timesTriedToDoThing) implements LightweightComponent {
@ComponentMapCodec
static final MapCodec<AttemptsComponent> MAP_CODEC =
Codec.INT.xmap(AttemptsComponent::new, AttemptsComponent::timesTriedToDoThing).fieldOf("times_tried_to_do_thing");
@ComponentStreamCodec
static final StreamCodec<RegistryFriendlyByteBuf, AttemptsComponent> STREAM_CODEC =
ByteBufCodecs.VAR_INT.cast().map(AttemptsComponent::new, AttemptsComponent::timesTriedToDoThing);
}
}
The component is now an immutable record, with persistence and syncing set up at definition, and handled by
a MapCodec
and StreamCodec
, rather than through implementing methods. The component is defined in the same place as it is used,
making the code easier to read, and allows us to get on with implementing the behaviour.
By reducing the friction from making components, we also make it more tempting to produce lots of smaller components for individual bits of data, rather than big overarching ones. I'm often put off from making smaller components due to the boilerplate.
Implementing this #
I'm fairly certain this could be implemented without any changes to cardinal components itself. Through an annotation processor at compile-time, mods using lightweight components could have all the extra component registration boilerplate added magically.
Side-note: interfaces #
Since Java 21 came around I'm often making sealed interfaces and pattern-matching over them. It'd be neat if I could also use those with my lightweight components:
@LightweightComponent(persist = true,
syncMode = SyncMode.ONLY_TO_OWNER,
attachTo = {AttachTo.PLAYERS},
respawnCopyStrategy = RespawnCopyStrategy.ALWAYS_COPY)
sealed interface MyComponent extends LightweightComponent {
@ComponentMapCodec
MapCodec<MyComponent> MAP_CODEC = deriveMapCodec();
@ComponentStreamCodec
StreamCodec<MyComponent> STREAM_CODEC = deriveStreamCodec();
record StateA(int i) implements MyComponent {
@ComponentMapCodec
static final MapCodec<StateA> MAP_CODEC =
Codec.INT.xmap(StateA::new, StateA::i).fieldOf("i");
@ComponentStreamCodec
static final StreamCodec<RegistryFriendlyByteBuf, StateA> STREAM_CODEC =
ByteBufCodecs.VAR_INT.cast().map(StateB::new, StateB::timesTriedToDoThing);
}
record StateB(int x, int y, int z) implements MyComponent {
@ComponentMapCodec
static final MapCodec<StateB> MAP_CODEC =
RecordCodecBuilder.mapCodec(instance -> instance.group(/* etc */)
.apply(instance, StateB::new));
@ComponentStreamCodec
static final StreamCodec<RegistryFriendlyByteBuf, StateB> STREAM_CODEC = StreamCodec.composite(/* etc */);
}
record StateC() implements MyComponent {
@ComponentMapCodec
static final MapCodec<StateC> MAP_CODEC =
MapCodec.unit(new StateC());
@ComponentStreamCodec
static final StreamCodec<RegistryFriendlyByteBuf, StateC> STREAM_CODEC =
StreamCodec.unit(new StateC());
}
}
Thoughts? 🤔 #
Would you use this? Is there something I haven't thought of? Please do send me a message. If you're reading this you probably know me.