https://github.com/adam4lexander/spoke
Spoke is a tiny declarative reactivity engine for Unity.
https://github.com/adam4lexander/spoke
architecture csharp declarative ecs game-dev gameplay open-source reactive reactivity simulation state-management unity unity3d vr
Last synced: 26 days ago
JSON representation
Spoke is a tiny declarative reactivity engine for Unity.
- Host: GitHub
- URL: https://github.com/adam4lexander/spoke
- Owner: Adam4lexander
- License: mit
- Created: 2025-05-06T10:50:09.000Z (9 months ago)
- Default Branch: main
- Last Pushed: 2025-06-08T01:16:00.000Z (8 months ago)
- Last Synced: 2025-06-08T02:40:00.245Z (8 months ago)
- Topics: architecture, csharp, declarative, ecs, game-dev, gameplay, open-source, reactive, reactivity, simulation, state-management, unity, unity3d, vr
- Language: C#
- Homepage:
- Size: 206 KB
- Stars: 21
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
# ๐ 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:
```csharp
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:
```csharp
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`:
```csharp
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.](./Docs/Core/07_SpokeTree.md#usage-with-spokebehaviour)
[Read the Quickstart โ](./Docs/Core/01_QuickStart.md)
---
## ๐ง 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.
```cs
// --- 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.
```cs
// 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 EnemyDetected;
public UnityEvent 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 EnemyDetected;
public UnityEvent 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 โ](./Docs/)
---
## ๐ฌ Performance
[See performance notes โ](./Docs/Topics/Performance.md)
---
## ๐งฐ Requirements
- Unity 2021.3 or later (For Examples)
- No packages, no dependencies
---
## ๐ License
MIT โ free to use in personal or commercial projects.