Adam4lexander / Spoke

Spoke is a tiny declarative reactivity engine for Unity.

Repository from Github https://github.comAdam4lexander/SpokeRepository from Github https://github.comAdam4lexander/Spoke

๐Ÿ”˜ Spoke - A reactive framework for simulated worlds

Spoke is a tiny reactivity engine for C# and Unity.
Itโ€™s tree-shaped and declarative, with imperative-ordered execution, and built to tame the chaos when many systems interact in dynamic, emergent ways.

  • Start and stop behaviours at the right time, keeping them in sync with runtime state.
  • Manage deeply nested logic while keeping it cohesive and clear.

Inspired by React, Spoke adds strict guarantees on execution order, making it suitable for game logic. It makes indirect, entangled logic feel imperative, with automatic lifecycle management and self-cleaning reactivity.

No flag-checking. No brittle events. No manual cleanup.
Just stateful blocks of logic, expressed as a tree, that mount and unmount on their own.

  • โœจ Control complexity โ€” write clear, reactive gameplay logic
  • ๐Ÿงช Use anywhere โ€” adopt in one script, one system, or your whole project

โšก Example

Spawn a HUD over the nearest enemy

๐ŸŸง Vanilla Unity:

GameObject currHUD;

void Awake() {
    OnNearestEnemyChanged.AddEventListener(NearestEnemyChangedHandler);
}

void OnDestroy() {
    OnNearestEnemyChanged.RemoveEventListener(NearestEnemyChangedHandler);
    if (currHUD != null) Destroy(currHUD);
}

void NearestEnemyChangedHandler(GameObject enemy) {
    if (currHUD != null) Destroy(currHUD);
    if (enemy != null) currHUD = SpawnHUD(enemy);
}

๐ŸŸฆ Spoke:

void Init(EffectBuilder s) {
    if (s.D(NearestEnemy) == null) return;
    var hud = SpawnHUD(NearestEnemy.Now);
    s.OnCleanup(() => Destroy(hud));
}

๐Ÿ‘‰ In Spoke, the entire behaviour lives in one expressive block. Setup, reaction, and cleanup happen automatically.


๐Ÿ’ก Why Spoke?

Unityโ€™s lifecycle makes it easy for logic to get scattered:

  • Systems spread across Awake, OnEnable, OnDisable, OnDestroy
  • Polling state in Update just to detect changes
  • Brittle event chains with manual subscription cleanup
  • Initialization order bugs between dependent components
  • Scene teardown chaos: accessing destroyed objects

Spoke collapses those problems into scoped, self-cleaning windows of logic.
You write: "When this state exists โ€” run this behaviour โ€” and clean it up afterward.โ€


๐Ÿ”ฐ Install

Clone this repo or copy Spoke.Runtime, Spoke.Reactive and Spoke.Unity into your project.
No dependencies, no setup.


๐Ÿš€ Getting Started

Subclass SpokeBehaviour instead of MonoBehaviour:

using Spoke;

public class MyBehaviour : SpokeBehaviour {

    // Replaces Awake, OnEnable, Start, OnDisable, OnDestroy
    protected override void Init(EffectBuilder s) {

        // Awake logic here

        s.OnCleanup(() => {
            // OnDestroy logic here
        });

        s.Phase(IsEnabled, s => {
            // OnEnable logic here
            s.OnCleanup(() => {
                // OnDisable logic here
            });
        });

        s.Phase(IsStarted, s => {
            // Start logic here
        });
    }
}

Or spawn a SpokeTree in your own scripts.

Read the Quickstart โ†’


๐Ÿง  Core Concepts

The reactive model behind Spoke is built around a few simple primitives:

  • Trigger - fire-and-forget events
  • State - reactive container for values
  • Effect / Phase / Reaction - self-cleaning blocks of logic
  • Memo - computed reactive value
  • Dock - dynamic reactive container

๐Ÿค” "Spoke-style" reactivity?

Spoke shares DNA with frameworks like React and SolidJS
Instead of managing a DOM tree, you're sculpting simulation logic: behaviour trees, stateful systems, emergent gameplay.

These frameworks transformed how we write UI.
Spoke applies the same principles to gameplay logic.


๐ŸŽฎ Origins

Spoke was born out of necessity while building my VR mech game, Power Grip Dragoons. The game has brutal demands for dynamic, event-driven logic on Meta Quest hardware. Over 6 years I refined this architecture until it became the foundation I now use everywhere. Spoke is the result.


๐Ÿ” Real-World Patterns

Scattered Resource Management

Managing disposables in Unity usually means spreading logic across lifecycle methods.

// --- MonoBehaviour
public class MyBehaviour : MonoBehaviour {

    IDisposable myResource;

    void OnEnable() {
        myResource = new SomeCustomResource();
    }

    void OnDisable() {
        myResource.Dispose();
    }
}

// --- Spoke
public class MySpokeBehaviour : SpokeBehaviour {

    protected override void Init(EffectBuilder s) {
        s.Phase(IsEnabled, s => {
            s.Use(new SomeCustomResource());
        });
    }
}

In Spoke, resource allocation and cleanup collapse into one scoped block. No more lifecycle bugs scattered across methods.


Chained Event Subscriptions

Nested event subscriptions (EnemyDetected โ†’ EnemyDestroyed) get messy fast.

// When an enemy is detected on radar, and it becomes destroyed. Then the
// cockpit voice (BitchinBetty) should speak the phrase: "Enemy Destroyed".

// --- MonoBehaviour
public class MyBehaviour : MonoBehaviour {

    public UnityEvent<RadarBlip> EnemyDetected;
    public UnityEvent<RadarBlip> EnemyLost;

    void Awake() {
        EnemyDetected.AddListener(HandleEnemyDetected);
        EnemyLost.AddListener(HandleEnemyLost);
    }

    void OnDestroy() {
        EnemyDetected.RemoveListener(HandleEnemyDetected);
        EnemyLost.RemoveListener(HandleEnemyLost);
    }

    void HandleEnemyDetected(RadarBlip enemy) {
        enemy.OnDestroyed.AddListener(HandleEnemyDestroyed);
    }

    void HandleEnemyLost(RadarBlip enemy) {
        enemy.OnDestroyed.RemoveListener(HandleEnemyDestroyed);
    }

    void HandleEnemyDestroyed() {
        BitchinBetty.SpeakEnemyDestroyed();
    }
}

// --- Spoke
public class MySpokeBehaviour : SpokeBehaviour {

    public UnityEvent<RadarBlip> EnemyDetected;
    public UnityEvent<RadarBlip> EnemyLost;

    protected override void Init(EffectBuilder s) {
        var dock = s.Dock();
        s.Subscribe(EnemyDetected, enemy => dock.Effect(enemy, s => {
            s.Subscribe(enemy.OnDestroyed, BitchinBetty.SpeakEnemyDestroyed);
        }));
        s.Subscribe(EnemyLost, enemy => dock.Drop(enemy));
    }
}

In Spoke, the entire subscription chain lives in one cohesive block. Setup and teardown are automatic. No missed unsubscribes.


Both patterns are really the same thing, they're lifecycle windows. With Spoke, you declare what happens in a window, how windows nest, and how to clean up when they end.


๐Ÿ“˜ Documentation

Read the full documentation โ†’


๐Ÿ”ฌ Performance

See performance notes โ†’


๐Ÿงฐ Requirements

  • Unity 2021.3 or later (For Examples)
  • No packages, no dependencies

๐Ÿ“œ License

MIT โ€” free to use in personal or commercial projects.

About

Spoke is a tiny declarative reactivity engine for Unity.

License:MIT License


Languages

Language:C# 100.0%