VitalRouter, is a zero-allocation message passing tool for Unity (Game Engine). And the very thin layer that encourages MVP (or whatever)-like design. Whether you're an individual developer or part of a larger team, VitalRouter can help you build complex game applications.
Bring fast, declarative routing to your application.
[Routes]
[Filter(typeof(Logging))]
[Filter(typeof(ExceptionHandling))]
[Filter(typeof(GameStateUpdating))]
public partial class ExamplePresenter
{
// Declare event handler
public void On(FooCommand cmd)
{
// Do something ...
}
// Declare event handler (async)
public async UniTask On(BarCommand cmd)
{
// Do something for await ...
}
// Declare event handler with extra filter
[Filter(typeof(ExtraFilter))]
public async UniTask On(BuzCommand cmd, CancellationToken cancellation)
{
// Do something after all filters runs on.
}
}
Feature | Description |
---|---|
Declarative routing | The event delivery destination and inetrceptor stack are self-explanatory in the type definition. |
Async/non-Async handlers | Integrate with async/await (with UniTask), and providing optimized fast pass for non-async way |
With DI and without DI | Auto-wiring the publisher/subscriber reference by DI (Dependency Injection). But can be used without DI for any project |
Thread-safe N:N pub/sub | Built on top of a thread-safe, in-memory, asynchronized pub/sub system, which is critical in game design. Due to the async task's exclusivity control, events are characterized by being consumed in sequence. So it can be used as robust FIFO queue. |
FIFO (First in first out), Fan-out | In Game, it is very useful to have events processed in series, VitalRouter provide FIFO constraints. it is possible to fan-out to multiple FIFOs in concurernt. |
- Installation
- Getting Started
- Publish
- Interceptors
- FIFO
- DI scope
- Command pooling
- Sequence Command
- Low-level API
- Concept, Technical Explanation
- Lisence
Prerequirements:
- Unity 2022.2+
- This limitation is due to the use of the Incremental Source Generator.
- Install UniTask
- Currently, VitalRouter uses UniTask instead of
UnityEngine.Awaitable
. UniTask is a fully featured and fast awaitable implementation. - In a future, if
UnityEngine.Awaitable
is enhanced in a future version of Unity, it may be migrated.
- Currently, VitalRouter uses UniTask instead of
- (optional) Install VContainer
- For bringing in DI style, VitalRouter supports Integration with VContainer, a fast and lightweight DI container for Unity.
Then, add git URL from Package Manager:
https://github.com/hadashiA/Vitalrouter.git?path=/VitalRouter.Unity/Assets/VitalRouter#0.2.0
First, define the data types of the event/message you want to dispatch. In VitalRouter this is called "command".
Any data type that implements ICommand
will be available as a command, no matter what the struct/class/record is.
public readonly struct FooCommand : ICommand
{
public int X { get; init; }
public string Y { get; init; }
}
public readonly struct BarCommand : ICommand
{
public Guid Id { get; init; }
public Vector3 Destination { get; init; }
}
Command is a data type (without any functionally). You can call it an event, a message, whatever you like. Forget about the traditional OOP "Command pattern" :) This library is intended for data-oriented design.
The name "command" is to emphasize that it is a operation that is "published" to your game system entirely. The word is borrowed from CQRS, EventStorming etc.
One of the main advantages of event being a data type is that it is serializable.
[Serializable] // < When you want to serialize to a scene or prefab in Unity.
[MessagepackObject] // < When you want to go through file or network I/O by MessagePack-Csharp.
[YamlObject] // < When you want to go through configuration files etc by VYaml.
public readonly struct CharacterSpawnCommand : ICommand
{
public long Id { get; init; }
public CharacterType Type { get; init; }
public Vector3 Position { get; init; }
}
In game development, the reason why the pub/sub model is used is because that any event will affect multiple sparse objects. See Concept, Technical Explanation section to more information.
Tip
Here we use the init-only property for simplicity. In your Unity project, you may need to add a definition of type System.Runtime.CompilerServices.IsExternalInit
as a marker.
However, this is not a requirement.
You are welcome to define the datatype any way you like.
Modern C# has additional syntax that makes it easy to define such data-oriented types. Using record or record struct is also a good option.
In fact, even in Unity, you can use the new syntax by specifying langVersion:11
compiler option or by doing dll separation. It would be worth considering.
Next, define the class that will handle the commands.
using VitalRouter;
// Classes with the Routes attribute are the destinations of commands.
[Routes]
public partial class FooPresentor
{
// This is called when a FooCommand is published.
public void On(FooCommand cmd)
{
// Do something ...
}
// This is called when a BarCommand is published.
public async UniTask On(BarCommand cmd)
{
// Do something for await ...
// Here, by awaiting for async task, the next command will not be called until this task is completed.
}
}
Types with the [Routes]
attribute are analyzed at compile time and a method to subscribe to Command is automatically generated.
Methods that satisfy the following conditions are eligible.
- public accesibility.
- The argument must be an
ICommand
, orICommand
andCancellationToken
. - The return value must be
void
, orUniTask
.
For example, all of the following are eligible. Method names can be arbitrary.
public void On(FooCommand cmd) { /* .. */ }
public async UniTask HandleAsync(FooCommand cmd) { /* .. */ }
public async UniTask Recieve(FooCommand cmd, CancellationToken cancellation) { /* .. */ }
Note
There are no restrictions by Interface but it will generate source code that will be resolved at compile time, so you will be able to follow the code well enough.
Now, when and how does the routing defined here call? There are several ways to make it enable this.
In a naive Unity project, the easy way is to make MonoBehaviour into Routes.
[Routes] // < If routing as a MonoBehaviour
public class FooPresenter : MonoBehaviour
{
void Start()
{
MapTo(Router.Default); // < Start command handling here
}
}
MapTo
is an automatically generated instance method.- When the GameObject is Destroyed, the mapping is automatically removed.
If you publish the command as follows, FooPresenter
will be invoked.
await Router.Default.PublishAsync(new FooCommand
{
X = 111,
Y = 222,
}
If you want to split routing, you may create a Router instance. As follows.
var anotherRouter = new Router();
MapTo(anotherRouter);
anotherRouter.PublishAsync(..)
If DI is used, plain C# classes can be used as routing targets.
using VContainer;
using VitalRouter;
using VitalRouter.VContainer;
// VContaner's configuration
public class GameLifetimeScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
builder.RegisterVitalRouter(routing =>
{
routing.Map<FooPresenter>(); // < Register routing plain class
// Or, use MonoBehaviour instance with DI
routing.MapComponent(instance);
// Or, use MonoBehaviour in the scene
routing.MapComponentInHierarchy<MyRoutesComponent>();
// Or, use MonoBehaviour from prefab
routing.MapComponentInNewPrefab(prefab);
});
}
}
The instances mapped here are released with to dispose of the DI container.
In this case, publisher is also injectable.
class SomeMyComponent : MonoBehaviour
{
[SerializeField]
Button someUIBotton;
ICommandPublisher publisher;
[Inject]
public void Construct(ICommandPublisher publisher)
{
this.publisher = publisher;
}
void Start()
{
someUIButton.onClick += ev =>
{
publisher.PublishAsync(new FooCommand { X = 1, Y = 2 }).Forget();
}
}
}
Just register your Component with the DI container. References are auto-wiring.
public class GameLifetimeScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
+ builder.RegisterComponentInHierarchy<SomeMyComponent>(Lifetime.Singleton);
builder.RegisterVitalRouter(routing =>
{
routing.Map<FooPresenter>();
});
}
}
Note
This is a simple demonstration. If your codebase is huge, just have the View component notify its own events on the outside, rather than Publish directly. And maybe only the class responsible for the control flow should Publish.
You can also set up your own entrypoint without using MonoBehaviour
or a DI container.
var presenter = new FooPresenter();
presenter.MapTo(Router.Default);
In this case, unmapping is required manually to discard the FooPresenter.
presenter.UnmapRoutes();
Or, handle subscription.
var subscription = presenter.MapTo(Router.Default);
// ...
subscription.Dispose();
ICommandPublisher
has an awaitable publish method.
ICommandPublisher publisher = Router.Default;
await publisher.PublishAsync(command);
await publisher.PublishAsync(command, cancellationToken);
If you await PublishAsync
, you will await until all Subscribers ([Routes]
class etc.) have finished all processing.
await publisher.PublishAsync(command1); // Wait until all subscribers have finished processing command1
await publisher.PublishAsync(command2); // Wait until all subscribers have finished processing command2
// ...
Note that by default, when Publish is executed in parallel, Subscribers is also executed in parallel.
publisher.PublishAsync(command1).Forget(); // Start processing command1 immediately
publisher.PublishAsync(command2).Forget(); // Start processing command2 immediately
publisher.PublishAsync(command3).Forget(); // Start processing command3 immediately
// ...
If you want to treat the commands like a queue to be sequenced, see FIFO section for more information.
The following is same for the above.
publisher.Enqueue(command1);
publisher.Enqueue(command2);
publisher.Enqueue(command3);
// ...
Enqueue
is an alias to PublishAsync(command).Forget()
.
Of course, if you do await
, you can try/catch all subscriber/routes exceptions.
try
{
await publisher.PublishAsync(cmd);
}
catch (Exception ex)
{
// ...
}
Interceptors can intercede additional processing before or after the any published command has been passed and consumed to subscribers.
Arbitrary interceptors can be created by implementing ICommandInterceptor
.
Example 1: Some kind of processing is interspersed before and after the command is consumed.
class Logging : ICommandInterceptor
{
public async UniTask InvokeAsync<T>(
T command,
CancellationToken cancellation,
Func<T, CancellationToken, UniTask> next)
where T : ICommand
{
UnityEngine.Debug.Log($"Start {typeof(T)}");
// Execute subsequent routes.
await next(command, cancellation);
UnityEngine.Debug.Log($"End {typeof(T)}");
}
}
Example 2: try/catch all subscribers exceptions.
class ExceptionHandling : ICommandInterceptor
{
public async UniTask InvokeAsync<T>(
T command,
CancellationToken cancellation,
Func<T, CancellationToken, UniTask> next)
where T : ICommand
{
try
{
await next(command, cancellation);
}
catch (Exception ex)
{
// Error tracking you like
UnityEngine.Debug.Log($"oops! {ex.Message}");
}
}
}
Example 3: Filtering command.
class MyFilter : ICommandInterceptor
{
public async UniTask InvokeAsync<T>(
T command,
CancellationToken cancellation,
Func<T, CancellationToken, UniTask> next)
where T : ICommand
{
if (command is FooCommand { X: > 100 } cmd)
{
// Deny. Skip the rest of the subscribers.
return;
}
// Allow.
await next(command, cancellation);
}
}
There are three levels to enable interceptor
- Apply globally to the
Router
. - Apply it to all methods in the
[Routes]
class. - Apply only to specific methods in the
[Routes]
class.
// Apply to the router.
Router.Default
.Filter(new Logging())
.Filter(new ErrorHandling);
// 1. Apply to the router with VContaienr.
builder.RegisterVitalRouter(routing =>
{
routing.Filters.Add<Logging>();
routing.Filters.Add<ErrorHandling>();
});
// 2. Apply to the type
[Routes]
[Filter(typeof(Logging))]
public partial class FooPresenter
{
// 3. Apply to the method
[Filter(typeof(ExtraInterceptor))]
public void On(CommandA cmd)
{
// ...
}
}
All of these are executed in the order in which they are registered, from top to bottom.
If you take the way of 2 or 3, the Interceptor instance is resolved as follows.
- If you are using DI, the DI container will resolve this automatically.
- if you are not using DI, you will need to pass the instance in the
MapTo
call.-
MapTo(Router.Default, new Logging(), new ErrorHandling());
-
// auto-generated public Subscription MapTo(ICommandSubscribable subscribable, Logging interceptor1, ErrorHandling interceptor2) { /* ... */ }
-
If you want to treat the commands like a queue to be sequenced, do the following:
// Set FIFO constraint to the globally.
Router.Default.FirstInFirstOut();
// Create FIFO router.
var fifoRouter = new Router().FirstInFirstOut();
// for DI
builder.RegisterVitalRouter(routing =>
{
routing.FirstInFirstOut();
});
In this case, the next command will not be delivered until all [Routes]
classes and Interceptors have finished processing the Command.
In other words, per Router, command acts as a FIFO queue for the async task.
publisher.PublishAsync(command1).Forget(); // Start processing command1 immediately
publisher.PublishAsync(command2).Forget(); // Queue command2 behaind command1
publisher.PublishAsync(command3).Forget(); // Queue command3 behaind command2
// ...
publisher.Enqueue(command1); // Start processing command1 immediately
publisher.Enqueue(command2); // Queue command2 behaind command1
publisher.Enqueue(command3); // Queue command3 behaind command2
// ...
When FIFO mode, if you want to group the awaiting subscribers, you can use FanOutInterceptor
Router.Default.FirstInFirstOutOrdering();
var fanOut = new FanOutInterceptor();
var groupA = new Router();
var groupB = new Router();
fanOut.Add(groupA);
fanOut.Add(groupB);
Router.Default.Filter(fanOut);
// Map routes per group
presenter1.MapTo(groupA);
presenter2.MapTo(groupA);
presente3.MapTo(groupB);
presente4.MapTo(groupB);
For DI,
public class SampleLifetimeScope : LifetimeScope
{
public override void Configure(IContainerBuilder builder)
{
builder.RegisterVitalRouter(routing =>
{
routing
.FirstInFirstOut()
.FanOut(groupA =>
{
groupA.Map<Presenter1>();
groupA.Map<Presenter2>();
})
.FanOut(groupB =>
{
groupB.Map<Presenter3>();
groupB.Map<Presenter4>();
})
});
}
}
Now we have the structure shown in the following:
flowchart LR
Default(Router.Default)
GroupA(groupA)
GroupB(groupB)
P1(presenter1)
P2(presenter2)
P3(presenter3)
P4(presenter4)
Default -->|fire and forget| GroupA
Default -->|fire and forget| GroupB
subgraph awaitable 1
GroupA --> P1
GroupA --> P2
end
subgraph awaitable 2
GroupB --> P3
GroupB --> P4
end
VContainer can create child scopes at any time during execution.
RegisterVitalRouter
inherits the Router defined in the parent.
For example,
public class ParentLifetimeScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
builder.RegisterVitalRouter(routing =>
{
routing.Map<PresenterA>();
});
builder.Register<ParentPublisher>(Lifetime.Singleton);
}
}
public class ChildLifetimeScope : LifetimeScope
{
protected override void Configure(IContainerBuilder builder)
{
builder.RegisterVitalRouter(routing =>
{
routing.Map<PresenterB>();
});
builder.Register<MyChildPublisher>(Lifetime.Singleton);
}
}
- When an instance in the parent scope publishes used
ICommandPublisher
, PresenterA and PresenterB receive it. - When an instance in the child scope publishes
ICommandPublisher
, also PresenterA and PresenterB receives.
If you want to create a dedicated Router for a child scope, do the following.
builder.RegisterVitalRouter(routing =>
{
+ routing.Isolated = true;
routing.Map<PresenterB>();
});
If Command is struct, VitalRouter avoids boxing, so no heap allocation occurs. This is the reson of using sturct is recommended.
In some cases, however, you may want to use class. Typically, when Command is treated as a collection element, boxing is unavoidable.
So we support the ability to pooling commands when classes are used.
public class MyBoxedCommmand : IPoolableCommand
{
public ResourceA ResourceA { ge; set; }
void IPoolableCommand.OnReturnToPool()
{
ResourceA = null!;
}
}
// To publish, use CommandPool for instantiation.
var cmd = CommandPool<MyBoxedCommand>.Shared.Rent(() => new MyBoxedCommand());
// Lambda expressions are used to instantiate objects that are not in the pool. Any number of arguments can be passed from outside.
var cmd = CommandPool<MyBoxedCommand>.Shared.Rent(arg1 => new MyBoxedCommand(arg1), extraArg);
var cmd = CommandPool<MyBoxedCommand>.Shared.Rent((arg1, arg2) => new MyBoxedCommand(arg1, arg2), extraArg1, extraArg2);
// ...
// Configure value
cmd.ResourceA = resourceA;
// Use it
publisher.PublishAsync(cmd);
// It is convenient to use the `CommandPooling` Interceptor to return to pool automatically.
Router.Default.Filter(CommandPooling.Instance);
// Or, return to pool manually.
CommandPool<MyBoxedCommand>.Shard.Return(cmd);
If your command implements IEnumerable<ICommand>
, it represents a sequence of time series.
var sequenceCommand = new SequenceCommand
{
new CommandA(),
new CommandB(),
new CommandC(),
// ...
}
ICommandSubscribale router = Router.default;
// Subscribe handler via lambda expression
router.Subscribe<FooCommand>(cmd => { /* ... */ });
// Subscribe async handler via lambda expression
router.Subscribe<FooCommand>(async cmd => { /* ... */ });
// Subscribe handler
router.Subscribe(Subscriber);
class Subscriber : ICommandSubscriber
{
pubilc void Receive<T>(T cmd) where T : ICommand { /* ... */ }
}
// Subscribe async handler
router.Subscribe(AsyncSubscriber);
class AsyncSubscriber : IAsyncCommandSubscriber
{
pubilc UniTask Receive<T>(T cmd, CancellationToken cancellation) where T : ICommand { /* ... */ }
}
// Add interceptor via lambda expresion
router.Filter<FooCommand>(async (cmd, cancellationToken, next) => { /* ... */ });
Unity is a very fun game engine that is easy to work with, but handling communication between multiple GameObjects is a difficult design challenge.
In the game world, there are so many objects working in concert: UI, game system, effects, sounds, and various actors on the screen.
It is common for an event fired by one object to affect many objects in the game world. If we try to implement this in a naive OOP way, we will end up with complex... very complex N:N relationships.
More to the point, individual objects in the game are created and destroyed at a dizzying rate during execution, so this N:N would tend to be even more complex!
There is one problem. There is no distinction between "the one giving the orders" and "the one being given the orders." In the simplicity of Unity programming, it is easy to mix up the object giving the orders and the object being given the orders. This is one of the reasons why game design is so difficult.
When the relationship is N:N, bidirectional binding is almost powerless. This is because it is very fat for an object to resolve references to all related objects. Moreover, they all repeat their creation.
Most modern GUI application frameworks recommend an overall unidirectional control flow rather than bidirectional binding.
Games are more difficult to generalize than GUIs. However, it is still important to organize the "control flow".
A major concern in game development is creating a Visualize Component that is unique to that game.
The Component we create has very detailed state transitions. It will move every frame. Maybe. It will have complex parent-child relationships. Maybe.
But we should separate this very detailed state management from the state that is brought to the entire game system and to which many objects react.
It is the latter fact that should be "publich".
Each View Component should hide its own state inside.
So how should granular components expose their own events to the outside world? Be aware of ownership. An object with ownership of a fine-grained object communicates further outside of it.
The "Controller" in MVC is essentially not controlled by anyone. It is the entry point of the system. VitalRouter does not require someone to own the Controller, only to declare it in an attribute. So it encourages this kind of design.
An important advantage of giving a type for each type of event is that it is serializable.
For example,
- If you store your commands in order, you can implement game replays later by simply re-publishing them in chronological order.
- Commands across the network, your scenario data, editor data, whatever is datamined, are the same input data source.
A further data-oriented design advantage is the separation of "data" from "functionality.
The life of data is dynamic. Data is created/destroyed when certain conditions are met in a game. On the other hand, "functionality" has a static lifetime. This is a powerful tool for large code bases.
MIT
@hadashiA