Skip to content

PodNet 2.0 — event system redesign

Design and implementation plan for a major version of PodNet, the in-house event bus used as the cross-system communication layer for the whole game (placement, grid, deletion, dev UI, and the upcoming guest/crew systems).

PodNet is a published Unity Asset Store product, so this is a semver-major (2.0.0) with a migration guide. Single-player only — zero networking code is ported. The design folds in the worthwhile ideas surfaced by comparing PodNet 1.2.0 against the MultiplayerEventManager redesign in ucg/racoon-riot (origin/purrnet-implementation), and rejects its networking layer, its mutable struct-key channel, its DynamicInvoke request dispatch, and its tap-only observability.

  1. Kill the per-call GC and reflection cost on the hot paths (resolves the EventManager-perf finding, #113).
  2. Real, honest thread-safety for a multi-threaded simulation (resolves the false-thread-safety-claim finding, #129 — by implementing it, not just documenting it away).
  3. First-class observability (error/warning/broadcast/request hooks) without a scene object, and with a Debug.Log fallback so nothing is silently swallowed.
  4. A more flexible event model (interface markers → struct-capable events, multi-membership category grouping) without losing the event hierarchy.
  5. Cheap instance-scoped channels for entity-local messaging (e.g. 200 guests each talking to their own sub-systems by instance id, with no string work).
# Decision Choice Why
1 Thread-safety Implement real thread-safety (copy-on-write dispatch) It’s a multi-threaded sim; the bus may be touched from worker/job threads.
2 Channel model readonly struct Channel { string Name; int Id }, immutable, O(1) keyed Entity-local messaging by instance id with zero string alloc; reject racoon’s mutable key + O(channels) scan.
3 Event value type Hybrid: structs for markers/value-commands/hot events; classes for responses Responses signal failure by returning null; a struct response can’t be null.
4 Migration Hard cutover (no compat shim) The sole in-repo consumer is migrated by a Roslyn codemod in hours; a shim doubles the type system.
5 Category taxonomy Folder-mirroring + entity categories Additive; exercises interface-set dispatch on a real hierarchy.
Main-thread delivery Include an optional main-thread dispatch queue Worker/job threads can raise events that deliver safely on the main thread (Unity APIs are main-thread-only).

The event hierarchy (preserved and upgraded)

Section titled “The event hierarchy (preserved and upgraded)”

Grouping happens on two orthogonal axes. This is the core mental model.

Axis 1 — type (“what kind of event is this?”) — supports inheritance

Section titled “Axis 1 — type (“what kind of event is this?”) — supports inheritance”

Events are marker interfaces, not base classes:

public interface IEvent { }
public interface IResponse : IEvent { }
public interface IRequest<TResponse> : IEvent where TResponse : IResponse { }

IRequest/IResponse extend IEvent because the request and the response are both broadcast through the bus, so both must be visible to catch-all and category subscribers.

Category interfaces give grouping with inheritance, replacing 1.2.0’s base-class chain:

public interface IGridEvent : IEvent { }
public interface IPlacementEvent : IGridEvent { }
public interface IGuestEvent : IEvent { }
public interface IGuestNeedEvent : IGuestEvent { }
public readonly struct HungerChanged : IGuestNeedEvent { /* ... */ } // a struct, zero-alloc

Subscription semantics (event runtime type R, its full implemented-interface set computed once and cached):

  • Subscribe<IEvent>(h) / SubscribeAll(h)real catch-all: h fires for every event, request, and response. This is your logger/debugger in one line — a genuine delivery tier, not the observability tap.
  • Subscribe<IGuestEvent>(h) → fires for every event implementing IGuestEvent (directly or transitively): HungerChanged, MoodChanged, …
  • Subscribe<HungerChanged>(h) → only that concrete type.

This is strictly more flexible than class inheritance: an event can join several categories at once (multiple interfaces) and can be a struct.

Dispatch must replace the runtime base-class-chain walk with an interface-set dispatch that still retains base-class grouping, so any remaining class-based events (and external 1.2.0 consumers) don’t regress.

Axis 2 — channel (“which instance/scope?”) — does not inherit

Section titled “Axis 2 — channel (“which instance/scope?”) — does not inherit”

The channel is an orthogonal routing label, matched by O(1) keyed lookup:

public readonly struct Channel : IEquatable<Channel>
{
public readonly string Name; // optional named channel, e.g. "audio"
public readonly int Id; // optional instance id, e.g. a guest's InstanceID
public Channel(string name) { Name = name; Id = 0; }
public Channel(int id) { Name = null; Id = id; }
public bool Equals(Channel other) => Id == other.Id && Name == other.Name;
public override int GetHashCode() => HashCode.Combine(Name, Id);
public static implicit operator Channel(string name) => new Channel(name);
public static implicit operator Channel(int id) => new Channel(id);
}

The 200-guests pattern this is built for:

// Guest 57's needs system → only guest 57's mood/animation systems. Zero string work.
EventManager.Broadcast(guest.InstanceId, new HungerChanged(0.2f));
EventManager.Subscribe<HungerChanged>(guest.InstanceId, mood.OnHunger);
// A global satisfaction tracker hears ALL guests — channelless, by type:
EventManager.Subscribe<HungerChanged>(analytics.OnAnyHunger);
EventManager.Subscribe<IGuestNeedEvent>(analytics.OnAnyNeed); // or the whole category

How receivers watch instance-scoped events — the two axes compose:

Subscription Receives
Subscribe<HungerChanged>() (channelless) every guest’s HungerChanged
Subscribe<HungerChanged>(57) only guest 57’s HungerChanged
Subscribe<IGuestNeedEvent>(57) every need-type event for guest 57
Subscribe<IEvent>() everything (logger)

A channelled broadcast still fans out to channelless type/category/global subscribers, so global listeners keep hearing everything of a type regardless of channel.

One channel per broadcast. An event carries one type-set (its interfaces — the what) and is broadcast to one channel (the which/scope); that keeps every lookup O(1). Cross-cutting relevance (“everyone on deck 3”) is expressed on the type/category axis, not by tagging an event with many channels. Multi-channel-per-broadcast is intentionally not in the core — it is cheap to add later if a real case appears (iterate the event’s channel list, each an O(1) keyed lookup; never racoon’s O(all-channels) scan).

Dispatch & thread-safety — copy-on-write

Section titled “Dispatch & thread-safety — copy-on-write”

Real thread-safety and zero-allocation dispatch come from the same mechanism:

  • Each (type-or-channel) key holds an immutable array of subscribers.
  • Dispatch (the hot path) reads the current array reference once (atomic) and iterates — lock-free, zero allocation, callable from any thread.
  • Subscribe/Unsubscribe take a per-key lock, build a new array with the entry added/removed, and atomically swap it in. Mutation allocates; dispatch does not. Subscription churn (≈ guest spawn/despawn) is far rarer than dispatch.
  • Outer tables are ConcurrentDictionary.

This also gives well-defined reentrancy for free: an in-flight broadcast runs against the snapshot it started with — a listener that subscribes mid-dispatch is seen on the next broadcast; one that unsubscribes mid-dispatch still receives the in-flight event. (This single mechanism replaces both racoon’s broken lock-registration-only scheme and a reverse-index/tombstone approach.)

Request/response dispatch captures a typed closure at registration time — Func<IEvent,TResponse> invoker = e => handler((TRequest)e) — so there is no per-call GetMethod("Invoke") reflection and no object[] boxing. (PodNet can do this where racoon couldn’t, because RegisterHandler<TRequest,TResult> knows both type parameters.)

Main-thread delivery. Even with a thread-safe bus, a listener that touches Unity APIs must run on the main thread. Broadcast/Request invoke synchronously on the calling thread; an additive BroadcastDeferred(...) enqueues the event to a thread-safe queue drained on the main thread via a PlayerLoop hook, so worker/job threads can raise events that deliver main-thread-safe.

Result types. Result<T> / PollResult<T> gain an explicit Success flag (struct responses are never null, so the 1.2.0 response != null test is invalid); Result becomes a readonly struct; Success becomes a readonly struct Success : IResponse. Error aggregation, GetErrorMessages, and the implicit bool are kept. Handler exceptions surface as the raw exception type (no TargetInvocationException wrapper, since reflection invoke is gone).

Four static hooks on EventManager, payloads as readonly structs:

public static event Action<EventError> OnError;
public static event Action<EventWarning> OnWarning;
public static event Action<EventBroadcastInfo> OnEventBroadcast;
public static event Action<RequestInvokeInfo> OnRequestInvoke;
  • OnError/OnWarning always reach the console (hook if attached, else Debug.LogError/LogWarning) — fixing racoon’s swallow-when-no-debugger flaw and honouring the project’s no-silent-fail rule.
  • OnEventBroadcast/OnRequestInvoke are pure taps, null-guarded so they cost nothing (no alloc/boxing) when unattached.
  • The EventDebugger is reworked from a bus catch-all into a hook subscriber, plus a UI-Toolkit PodNet → Event Monitor editor window attached directly to the hooks (decoupled from any scene object).

The OnEventBroadcast tap is an additional diagnostic, explicitly not a replacement for the Subscribe<IEvent> catch-all delivery tier.

Sequenced by blast radius so the remote stays green and the high-value work lands first. ~9–13 working days total; ~40% of the effort (Phases 0–1) carries ~70% of the value.

Phase 0 — Unity CI + green baseline (prerequisite) — non-breaking

Section titled “Phase 0 — Unity CI + green baseline (prerequisite) — non-breaking”

Stand up the missing Unity EditMode test gate (this is issue #109) and freeze the ~176-test PodNet suite as the regression fence. Reconcile the editor version (ProjectVersion.txt = 6000.5.1f1 vs the stale CLAUDE.md value). Provision the Unity license as org GH secrets. Exit: suite green in CI and locally; merge gated on green.

Phase 1 — Non-breaking internal rewrite (ships as 1.3.0) — non-breaking

Section titled “Phase 1 — Non-breaking internal rewrite (ships as 1.3.0) — non-breaking”

Resolves #113 and #129.

  • Typed-closure request dispatch (delete the per-call reflection + boxing).
  • Copy-on-write thread-safe, zero-alloc dispatch (delivers real thread-safety internally with no API change; replaces per-call ToArray() snapshots).
  • Lazy List<Exception> / Array.Empty on success paths.
  • Per-listener try/catch isolation on the channelled paths (fixes the real bug where one throwing channel listener aborts the broadcast).
  • Loud-fail instead of the silent as Broadcast → null and no-handler cases.
  • Bundled consumer correctness fixes (non-API): single placement-height authority (replace the duplicate Increase/Decrease/SetPlacementHeight handler registered by both ObjectPlacer and FloorPlacer); delete the dead deck triad and the unused GetGridObject surface.

Exit: 176 baseline tests pass unchanged; new tests prove typed dispatch, reentrancy decision table, channelled isolation, success-path no-alloc, loud-fail, and concurrency safety. Tag 1.3.0.

Phase 2 — Observability hooks + Event Monitor — additive

Section titled “Phase 2 — Observability hooks + Event Monitor — additive”

The four hooks with Debug.Log fallback; EventDebugger reworked onto them; the UI-Toolkit Event Monitor window. Exit: hooks fire once per broadcast/request with correct channel attribution; zero-alloc when unattached; errors/warnings still reach the console with no hook attached.

Phase 3 — Breaking core: IEvent/IRequest/IResponse + interface-set dispatch + Channel struct — breaking

Section titled “Phase 3 — Breaking core: IEvent/IRequest/IResponse + interface-set dispatch + Channel struct — breaking”
  • The marker-interface triad; delete Broadcast/Request<T> base members (MessageType, Timestamp, Details, ToString, ResponseType); human-readable detail becomes opt-in IDescribable.
  • Flip the dispatch cache from base-class chain to the implemented-interface set (retaining base-class grouping); global tier folds into the typeof(IEvent) key.
  • readonly struct Channel { Name; Id } with implicit string/int conversions; O(1) keyed lookup; reject the mutable-key/linear-scan design.
  • Result/Success become structs with an explicit Success flag.
  • Finalize thread-safety; remove the false “thread-safe” doc claims.
  • Optional main-thread dispatch queue (BroadcastDeferred).

Exit: ported test matrix proves catch-all with no hook attached, category fan-out (incl. multi-membership, no duplicate delivery), retained base-class grouping, channel orthogonality + instance-scoping, and Result.Success decoupled from null.

Phase 4 — Consumer migration — breaking

Section titled “Phase 4 — Consumer migration — breaking”

Roslyn codemod over the ~41 consumer event files (: Broadcast: IEvent/category interface; : Request<T>: IRequest<T>; remove Details overrides). Struct vs class per the hybrid rule. Define the category taxonomy (IGridEvent/IPlacementEvent/IStructureEvent/IFloorEvent/IWallEvent/IDeletionEvent, IInputEvent, IGuestEvent/IGuestNeedEvent). Migrate PodNet’s bundled Example + tests. Exit: paradise-fleet compiles and plays (placement, multi-deck, deletion verified); PodNet tests green on the new API.

Phase 5 — Release artifacts + 2.0.0 — breaking

Section titled “Phase 5 — Release artifacts + 2.0.0 — breaking”

README/CHANGELOG bump; MIGRATION-GUIDE.md (: Broadcast → interface; Details/Timestamp/MessageType replacements; Result.Success semantics; channel API; a find/replace cheat-sheet); Asset Store update notes; consider shipping as a real UPM package (package.json + asmdefs). Exit: CI green; artifacts published; 2.0.0 tagged; a clean-room follow of the guide compiles a sample consumer.

Breaking changes (for the migration guide)

Section titled “Breaking changes (for the migration guide)”
  • Broadcast / Request<TResponse> base classes removed → IEvent / IResponse / IRequest<TResponse> marker interfaces (~41 event files + ~52 subclassing files).
  • Base members gone: MessageType, Timestamp (no DateTime.Now-per-event), Details, ToString, Request.ResponseType → opt-in IDescribable.
  • Global catch-all Subscribe(Action<Broadcast>)Subscribe(Action<IEvent>) + SubscribeAll.
  • Generic constraints where T : Broadcastwhere T : IEvent.
  • Result<T>.Success no longer derived from response != null (explicit flag); Result and Success become structs.
  • string channel → Channel struct (implicit string/int conversions keep most call sites compiling); channel filtered tier becomes interface-aware.
  • Handler exceptions surface raw (no TargetInvocationException wrapper).
  • EventDebugger subscribes to hooks, not the bus catch-all.
  • Unsubscribe-during-dispatch is deferred (snapshot semantics) — a listener that unsubscribes mid-dispatch still receives the in-flight event.
  • False “thread-safe” claims removed; thread-safety now actually implemented.

This is a large infra investment that should not displace demo feature work.

  • Phase 0 (#109) is a cheap unblock — do it first; it protects every later step.
  • Phases 1–2 are demo-safe, non-breaking, and ship as 1.3.0; they retire #113/#129 and are worth doing even if the 2.0 break is later cancelled.
  • Phases 3–5 are the breaking redesign and the safe cut point if the demo calendar tightens — ship 1.3.0 and defer the major.

The breaking work’s honest ROI: for a low-frequency single-player title the GC win is mostly moot once Phase 1 lands (struct events still box once per dispatch tier); the real payoff is API flexibility, instance-scoped channels for the guest systems, real thread-safety, and a clean 2.0 on a published asset.