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.
- Kill the per-call GC and reflection cost on the hot paths (resolves the EventManager-perf finding, #113).
- 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).
- First-class observability (error/warning/broadcast/request hooks) without a
scene object, and with a
Debug.Logfallback so nothing is silently swallowed. - A more flexible event model (interface markers → struct-capable events, multi-membership category grouping) without losing the event hierarchy.
- 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).
Decisions
Section titled “Decisions”| # | 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-allocSubscription semantics (event runtime type R, its full implemented-interface set
computed once and cached):
Subscribe<IEvent>(h)/SubscribeAll(h)→ real catch-all:hfires 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 implementingIGuestEvent(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 categoryHow 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).
Observability
Section titled “Observability”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/OnWarningalways reach the console (hook if attached, elseDebug.LogError/LogWarning) — fixing racoon’s swallow-when-no-debugger flaw and honouring the project’s no-silent-fail rule.OnEventBroadcast/OnRequestInvokeare pure taps, null-guarded so they cost nothing (no alloc/boxing) when unattached.- The
EventDebuggeris 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
OnEventBroadcasttap is an additional diagnostic, explicitly not a replacement for theSubscribe<IEvent>catch-all delivery tier.
Phased plan
Section titled “Phased plan”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.Emptyon success paths. - Per-listener
try/catchisolation 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/SetPlacementHeighthandler registered by bothObjectPlacerandFloorPlacer); delete the dead deck triad and the unusedGetGridObjectsurface.
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-inIDescribable. - 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 implicitstring/intconversions; O(1) keyed lookup; reject the mutable-key/linear-scan design.Result/Successbecome structs with an explicitSuccessflag.- 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(noDateTime.Now-per-event),Details,ToString,Request.ResponseType→ opt-inIDescribable. - Global catch-all
Subscribe(Action<Broadcast>)→Subscribe(Action<IEvent>)+SubscribeAll. - Generic constraints
where T : Broadcast→where T : IEvent. Result<T>.Successno longer derived fromresponse != null(explicit flag);ResultandSuccessbecome structs.stringchannel →Channelstruct (implicitstring/intconversions keep most call sites compiling); channel filtered tier becomes interface-aware.- Handler exceptions surface raw (no
TargetInvocationExceptionwrapper). EventDebuggersubscribes 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.
Sprint fit
Section titled “Sprint fit”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.0and 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.