loomgui.com ↗

State, collections, and events

A bridge surface is built from three things: [BridgeState] properties (engine → UI state), reactive collections for lists and maps, and Event<T> for one-shot fan-outs.

State properties

State is a plain { get; set; } property tagged [BridgeState]. Assigning it pushes the new value to the UI; reading it returns the current value. Set the initial value in the property initializer.

[BridgeState] public int Score { get; set; } = 0;

// somewhere in your game logic:
Score = 100;        // triggers a state push
int now = Score;    // read back

The setter is required: Loom rewrites it to sync, so assigning the property is what pushes. There’s no separate subscribe step and no .Value wrapper; the property is the value. A push is skipped when the assigned value equals the current one.

Nested DTO state works the same way: a [BridgeState] property whose type is a DTO pushes wholesale when reassigned. Give the nested class its own [BridgeState] members to push a single inner field on its own. See DTO conventions.

Collections

You have two ways to expose a list or map, depending on how it changes.

Snapshot collections

List<T>, T[], and Dictionary<K,V> are pushed as a whole snapshot. The UI sees a typed array or object. Reassign the property to sync; mutating it in place won’t push.

[BridgeState] public string[] Messages { get; set; } = new[] { "hello" };

Messages = new[] { "hi", "there" };   // pushes the new snapshot
// Messages[0] = "x";                  // does not push; reassign instead

Reactive collections

For lists and maps that change often, ReactiveList<T> and ReactiveMap<K,V> push granular operations (a single insert, remove, move, or element change) instead of resending the whole collection. Declare them get-only and mutate in place:

[BridgeState] public ReactiveList<Player> Players { get; } = new();

Players.Add(new Player { Name = "Ada", Score = 0 });
Players.RemoveAt(0);
Players[0].Score += 1;   // pushes only that element's score

Element types use [BridgeState] properties so a single field change pushes just that field, not the whole element. ReactiveMap keys must not contain . or #. The UI sees a keyed, ordered collection it can render with a keyed <For>; C# owns the order.

Event<TPayload>

A one-shot fan-out. C# fires with a payload; the UI receives a typed callback.

C#
[BridgeEvent] public Event<DamageEvent> Damaged { get; } = new();

Damaged.Fire(new DamageEvent { Amount = 10 });
TS
onEvent('damaged', (e) => {
  flashRed(e.amount)
})

Events do not buffer. If the UI is not yet connected (e.g. boot phase), fired events are dropped. If your engine fires an event before the UI is up, either delay the fire or use a state field instead.

Threading

State, collections, and events are all designed for the main thread. Pushing from a background thread is undefined behaviour. If you need to update from a worker, marshal back to the main thread first (e.g. UnityMainThreadDispatcher.Enqueue(...), or your engine’s equivalent).