· 6 min · #unity #architecture #vcontainer
Bloom & Place devlog #11: bindings and event flow
How every system gets registered with VContainer, why each lifetime was picked, and the seven events that carry runtime communication.
Two pictures, one architecture
Loose coupling has two halves. The static half is who knows whom at startup, which is the VContainer binding scheme. The dynamic half is who talks to whom at runtime, which is the event flow. This post is both.
Binding scheme
Every system registered, with the lifetime chosen for it. Lifetime selection is most of the design work; pick it wrong and you either re-create state that should be shared, or share state that should reset.
- CharacterSystemPlant data, constant for whole session.
- CustomCursorManagerCursor active across levels (DontDestroyOnLoad).
- PlacementEvents · LevelEventsEvent holders. Same instance for pub/sub.
- PlacementSystemPer-level placement state.
- SatisfactionSystemPer-level satisfaction calc, resets on transition.
- LevelSystemPer-level win/lose state.
The decision rule, written down so I can apply it to systems I haven’t built yet:
- Singleton if the state has the same identity for the whole session.
- Scoped if the state should reset when the scene changes.
- Transient if the object has no identity at all and you legitimately want a fresh one each time. (None of the four core systems qualify.)
What the registration code looks like
public sealed class GameLifetimeScope : LifetimeScope {
protected override void Configure(IContainerBuilder builder) {
builder.Register<ICharacterSystem, CharacterSystem>(Lifetime.Singleton);
builder.Register<ICustomCursor, CustomCursorManager>(Lifetime.Singleton);
builder.Register<IPlacementSystem, PlacementSystem>(Lifetime.Scoped);
builder.Register<ISatisfactionSystem, SatisfactionSystem>(Lifetime.Scoped);
builder.Register<ILevelSystem, LevelSystem>(Lifetime.Scoped);
builder.Register<PlacementEvents>(Lifetime.Singleton);
builder.Register<LevelEvents>(Lifetime.Singleton);
builder.RegisterEntryPoint<GameLoop>();
}
}
Two things to flag in this snippet, because they’re easy to miss:
- Every domain system is registered against an
I*interface, not its concrete type. Consumers ask the container forISatisfactionSystem, neverSatisfactionSystem. - Event holder classes are singletons. The state they own is just delegate subscriptions, which need to be the same instance for publishers and subscribers to find each other.
Event flow
Seven events carry every runtime cross-system signal. Each event has exactly one publisher and one or more subscribers. No global bus.
- PlantOnPlantClickedPlantSelectionManager
- PlantOnPlantSelectedCustomCursorManager · PlantParticleEffect
- PlacementSystemOnPlantPlacedSatisfactionSystem · VisualFeedbackSystem
- SatisfactionSystemOnPlantSatisfiedVisualFeedbackSystem · LevelSystem
- SatisfactionSystemOnPlantDissatisfiedVisualFeedbackSystem
- LevelSystemOnLevelCompleteUISystem · SceneManager
- LevelSystemOnLevelFailedUISystem
Reading the flow
Two patterns repeat across the seven events:
- Fan-out is normal. Three of the seven events have multiple subscribers (
OnPlantSelected,OnPlantPlaced,OnPlantSatisfied). Each subscriber added is zero edits to the publisher. This is the leverage that makes the architecture worth the upfront work. - Manager-layer systems publish, view-layer systems subscribe.
VisualFeedbackSystemappears in subscriber columns four times and never as a publisher. That’s correct. View code should react, not initiate.
Implementation rule, in code
Every event holder is a plain C# class with Action or Action<T> delegates:
public sealed class PlacementEvents {
public event Action<Plant, Seat> OnPlantPlaced;
public event Action<Plant> OnPlantRemoved;
public void RaisePlanted(Plant p, Seat s) => OnPlantPlaced?.Invoke(p, s);
public void RaiseRemoved(Plant p) => OnPlantRemoved?.Invoke(p);
}
The discipline I’m enforcing on subscribers, written down because this is the memory-leak source in C# event code:
- Every
+=has a matching-=. Subscribe inOnEnable/ constructor, unsubscribe inOnDisable/Dispose. - No anonymous lambdas as subscribers. They’re impossible to unsubscribe by reference. Always use a named method.
Next
Devlog #12 covers the interface contracts that let two programmers build against each other in parallel without reading each other’s code, plus the test plan that turns the architecture claim into a measurable result.