Skip to content

Beacon Manager

shmellyorc edited this page Sep 14, 2026 · 4 revisions

The BeaconManager is Void's pub/sub system. It lets one system announce that something happened without knowing who's listening, and lets any number of other systems react without knowing who fired.

It's a topic table and a multicast Action<BeaconHandle> subscriber chain. Publishing performs a topic lookup and invokes that action directly.


Overview

Most engines couple systems directly. The player calls the HUD. The HUD calls the audio system. Every new reaction means another reference, another dependency, another reason you can't remove or reuse a system in isolation.

The alternative is an event bus: one object everyone knows about, and no one knows about each other. The player publishes "I died." Whoever cares subscribes.

Void implements this with BeaconManager and BeaconHandle. A topic, a callback, a payload. No interfaces, no event structs, no generic dispatch, no registration ceremony.

BeaconManager.Instance.Subscribe("PlayerDied", handle =>
{
    var position = handle.Get<Vect2>(0);
    // react
});

BeaconManager.Instance.Publish("PlayerDied", playerPosition, playerHealth);

Why Beacons?

Direct calls couple systems in both directions. If the player calls Hud.ShowDeathScreen(), then:

  • The player needs a reference to the HUD.
  • The HUD needs to be alive whenever the player is alive.
  • The player can't be tested or reused without dragging the HUD along.
  • Every new reaction (sound, achievement, save, screen shake) means another direct call from the player.

Beacon removes all four. The player publishes a topic. Whoever cares subscribes. Neither side knows the other exists.


Quick Start

Subscribe

BeaconManager.Instance.Subscribe("PlayerDied", OnPlayerDied);

private void OnPlayerDied(BeaconHandle handle)
{
    if (!handle.TryGet<Vect2>(0, out var position))
        return;

    ShowDeathScreen(position);
}

Publish

BeaconManager.Instance.Publish("PlayerDied", playerPosition, playerScore);

Publishing is synchronous. The subscriber chain runs on the thread that called Publish.

Publish After a Delay

PublishDelay is an extension method backed by the coroutine system. It keeps timing out of the core BeaconManager and ultimately uses the normal synchronous Publish path when the delay expires.

CoroutineHandle pending =
    BeaconManager.Instance.PublishDelay(
        GameBeacons.PlayerDied,
        2f,
        playerPosition,
        playerScore);

The delay uses scaled coroutine time, so it follows FrameTime.TimeScale. The returned handle can cancel the pending publish:

pending.Stop();

Unsubscribe

BeaconManager.Instance.Unsubscribe("PlayerDied", OnPlayerDied);

Clear All

BeaconManager.Instance.Clear();

Topics

A topic is a string that identifies an event. Two ways to define one:

String Topics

Fast, loose, no compiler help.

BeaconManager.Instance.Subscribe("WaveCleared", OnWaveCleared);
BeaconManager.Instance.Publish("WaveCleared", waveNumber);

Good for gamejams and prototypes. Typo "WaveCleared" as "Waveclear" and nothing tells you until a handler never fires.

Enum Topics

Typed, autocompleted, refactorable. Recommended for anything past a prototype.

public enum GameBeacons
{
    PlayerDied,
    GameStarted,
    WaveCleared,
    ScoreChanged
}

BeaconManager.Instance.Subscribe(GameBeacons.PlayerDied, OnPlayerDied);
BeaconManager.Instance.Publish(GameBeacons.GameStarted);

Internally an enum topic is converted to its name and hashed the same way a string topic is. Same dictionary, same lookup, same cost. The enum exists so the compiler and your IDE can help you.

Best practice: define every topic in one enum, treat that enum as the project's event contract, and never publish a raw string literal anywhere else. Your event list becomes discoverable, greppable, and refactorable.


The Handle

Subscribers receive a BeaconHandle, a readonly struct carrying the topic and a read-only view of the payload. The payload order is the same order used by Publish.

Get<TData>(int index)

Returns the payload at index as TData. Returns default if the index is out of range or the type does not match. Does not throw.

var position = handle.Get<Vect2>(0);   // Vect2, or default
var score    = handle.Get<int>(1);     // int, or 0

TryGet<TData>(int index, out TData data)

Returns true and sets data if the index is valid and the type matches. Returns false otherwise.

if (!handle.TryGet<Vect2>(0, out var position))
    return;

Properties

Property Type Description
Topic string The topic the beacon was published on.
Data ReadOnlySpan<object> Read-only view of the payload items.
Count int Number of payload items.

A subscriber can read payload items but cannot replace or reorder them through the handle.

BeaconHandle Convenience Extensions

For common positional payloads, VOID provides direct typed helpers. These access known indexes directly and do not scan the payload collection.

First payload item:

if (handle.TryGet(out Player player))
{
    // player is payload item 0
}

Multiple payload items can be read in one call, up to five values:

if (handle.TryGet<Vect2, int, string>(
    out var position,
    out var score,
    out var reason))
{
    // all three items exist and match
}

Other helpers include:

Player player = handle.Get<Player>();
int damage = handle.GetOr(1, 0);

bool hasPlayer = handle.Has<Player>();
bool hasDamage = handle.Has<int>(1);

bool isPlayerDied = handle.IsTopic(GameBeacons.PlayerDied);
bool hasData = handle.HasData();
bool empty = handle.IsEmpty();

Get<T>() and TryGet<T>(out T) use payload index zero. Indexed access remains available on BeaconHandle itself.


Full Example

A player dies. Three unrelated systems react. None of them know about each other.

The Contract

public enum GameBeacons
{
    PlayerDied,
    PlayerMoved,
    ScoreChanged
}

The Publisher

public void TakeDamage(int amount)
{
    _health -= amount;

    if (_health <= 0)
    {
        BeaconManager.Instance.Publish(
            GameBeacons.PlayerDied,
            Position,
            _score
        );
    }
}

The HUD

public class Hud
{
    public Hud()
    {
        BeaconManager.Instance.Subscribe(GameBeacons.PlayerDied, OnPlayerDied);
    }

    private void OnPlayerDied(BeaconHandle handle)
    {
        if (!handle.TryGet<Vect2>(0, out var position))
            return;

        ShowDeathScreen(position);
    }
}

The Audio System

public class AudioSystem
{
    public AudioSystem()
    {
        BeaconManager.Instance.Subscribe(GameBeacons.PlayerDied, OnPlayerDied);
    }

    private void OnPlayerDied(BeaconHandle handle)
    {
        PlaySound("death.wav");
    }
}

The Save System

public class SaveSystem
{
    public SaveSystem()
    {
        BeaconManager.Instance.Subscribe(GameBeacons.PlayerDied, OnPlayerDied);
    }

    private void OnPlayerDied(BeaconHandle handle)
    {
        if (!handle.TryGet<int>(1, out var score))
            return;

        WriteHighScore(score);
    }
}

Four systems, zero references between them. Remove the HUD and nothing breaks. Add a screen shake and you don't touch the player. Test the audio system with a fake publish and you don't need a player at all.


API Reference

BeaconManager

Member Description
Instance The singleton instance.
Count The number of subscribed topics.
Subscribe(string topic, Action<BeaconHandle> handle) Subscribe to a string topic.
Subscribe(Enum topic, Action<BeaconHandle> handle) Subscribe to an enum topic.
Unsubscribe(string topic, Action<BeaconHandle> handle) Remove a string-topic subscription. Returns true if removed.
Unsubscribe(Enum topic, Action<BeaconHandle> handle) Remove an enum-topic subscription.
Publish(string topic, params object[] data) Publish on a string topic.
Publish(Enum topic, params object[] data) Publish on an enum topic.
Clear() Remove all subscriptions.

Multiple handlers on the same topic are stored as a multicast Action<BeaconHandle>. Each Subscribe adds one callback and each Unsubscribe removes one. Publishing invokes the multicast action directly rather than manually iterating subscribers. When the last handler for a topic is removed, the topic is dropped.

BeaconManager Extensions

Member Description
PublishDelay(string topic, float seconds, params object[] data) Publish a string topic after a scaled coroutine delay and return a CoroutineHandle.
PublishDelay(Enum topic, float seconds, params object[] data) Publish an enum topic after a scaled coroutine delay and return a CoroutineHandle.

BeaconHandle

Member Description
Topic The topic string.
Data Read-only ReadOnlySpan<object> view of the payload.
Count Number of payload items.
Get<TData>(int index) Typed access. Returns default on failure.
TryGet<TData>(int index, out TData data) Safe typed access. Returns bool.

BeaconHandle Extensions

Member Description
Get<TData>() Get payload item zero as TData.
TryGet<T1>(out T1) Try to read payload item zero.
TryGet<T1, ... T5>(...) Try to read the first two through five positional payload items.
GetOr<TData>(fallback) Read item zero or return a fallback.
GetOr<TData>(index, fallback) Read an indexed item or return a fallback.
Has<TData>() Test item zero for a type.
Has<TData>(index) Test an indexed item for a type.
IsTopic(string / Enum) Test the handle's topic.
HasData() true when one or more payload items exist.
IsEmpty() true when the payload is empty.

Thread Safety

Core BeaconManager operations are safe to call concurrently. Topic storage uses a concurrent dictionary, while Subscribe, Unsubscribe, and Clear synchronize subscription mutations with each other. Publish performs a lock-free topic lookup and invokes the multicast action snapshot returned by that lookup.

This matters more than it sounds. Audio callbacks, asset loading, physics jobs, and networking may run on their own threads and may want to publish an event.

// Safe from any thread
BeaconManager.Instance.Publish(GameBeacons.AssetLoaded, assetName, handle);

Important: the subscriber callback runs on the publishing thread. If your handler touches UI or anything main-thread-bound, you are responsible for dispatching back to the main thread. Beacon makes the pub/sub operation safe; it does not make your handler thread-safe for you.

PublishDelay is different because it schedules work through CoroutineManager, which is intended for the game thread.


Best Practices

  • Define topics in one place. A single GameBeacons enum, or a static class of string constants. Never publish a string literal anywhere else.
  • Document the payload order. Put a comment next to the enum entry, or at the publish site: PlayerDied: (Vect2 position, int score).
  • Keep payloads small. One to three items. If you need more, define a struct and publish it as a single payload item.
  • Never change payload order. Add new items to the end. If you remove one, remove it from both sides in the same commit.
  • Prefer TryGet in handlers. Use indexed TryGet<T>(index, out value) or the positional extension overloads when reading several payload values.

Tradeoffs

Beacon is loosely typed. You can get the index or the type wrong and nothing tells you until runtime.

// Publisher
BeaconManager.Instance.Publish(GameBeacons.PlayerDied, position, score);

// Subscriber, wrong type
var player = handle.Get<Player>(0);   // returns default, silently

The mitigations are all convention: define topics centrally, document payloads, keep them small, never reorder them. None of this is compiler-enforced. That is the price of skipping the generic event framework.

For most game events, the trade is worth it. If you find yourself needing strict compile-time type safety on events, that's a sign your event contract has grown large enough to deserve its own types.


What Beacon Doesn't Do

Beacon is deliberately small. It does not provide:

  • Priorities or ordering guarantees beyond registration order
  • Event propagation or bubbling
  • Cancellation or vetoing
  • Wildcards or pattern matching
  • History or replay
  • "Before" and "after" hooks

If you need any of these, build them on top of Beacon. The manager does not prevent it. It simply does not do it for you, so that every project doesn't pay for features most projects don't need.


See Also

Clone this wiki locally