- Notifications
You must be signed in to change notification settings - Fork1
Simple, synchronous, single-threaded reactive programming primitives and collections with fluent bindings. Sync guarantees deterministic execution and defers mutations when executing bindings, protecting your code from reentrancy issues.
License
chickensoft-games/Sync
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
Simple, synchronous, single-threaded reactive programming primitives and collections with fluent bindings. Sync guarantees deterministic execution and defers mutations when executing bindings, protecting your code fromreentrancy issues.
Sync enforces correctness by default, minimizes memory allocations, and simplifies creating new reactive primitives composed of atomic operations.
Sync is a C# library that works everywherenetstandard2.1 works.
- ✅ Simplified terminology tailored for game development use cases.
- ✅ Avoids boxing value types and minimizes heap allocations to reduce garbage collector pressure (suitable for games).
- ✅ Includes observable collections such as
AutoList<T>,AutoSet<T>, andAutoMap<TKey, TValue>which are built on top of .NET's standard collection types. - ✅ Provides an observable property/value (or
BehaviorSubjectinReactiveX terminology) calledAutoValue<T>. - ✅ Errors stop execution immediately, same as ordinary C# code.
- ✅ Consistent, fluent bindings tailored for each reactive primitive.
- ✅ Dispose of bindings to unsubscribe from notifications.
- 🤩Easily build your own synchronous reactive primitives and collections composed of atomic operations and notify listeners without having to worry aboutreentrancy.
Tip
Reactive primitives are synchronous event loops which use a few tricks to essentially eliminate heap allocations in performance critical hot paths.
Here's a very simple, real-world game development example that shows how to idiomatically use Sync'sAutoValue<T> to synchronize an Enemy's visual representation with its underlying model.
Note
TheAutoValue<int> and the binding to itAutoValue<int>.Binding need to be cleaned up when you're finished to avoid memory leaks.
// Enemy gameplay logicpublicsealedclassEnemy:IDisposable{// mutable observable value private to this classprivatereadonlyAutoValue<int>_health=new(100);// immutable view of the value for outside subscriberspublicIAutoValue<int>Health=>_health;publicvoidTakeDamage(intdamage){// enemy can't take more damage than it has healthvarappliedDamage=Math.Min(Math.Abs(damage),_health.Value);// bindings will be notified when this goes into effect_health.Value-=appliedDamage;}publicvoidDispose(){// release references to any bindings to the health value so they can be GC'd_health.Dispose();}}// Enemy visualization logicpublicsealedclassEnemyView:IDisposable{publicEnemyEnemy{get;}publicAutoValue<int>.BindingBinding{get;}publicEnemyView(Enemyenemy){Enemy=enemy;// listen to changes in the enemy's healthBinding=enemy.Health.Bind();Binding.OnValue(OnHealthChanged);}publicvoidOnHealthChanged(inthealth){// update the health bar UI, etc.}publicvoidDispose(){Binding.Dispose();// stop listening}}
Tip
By convention, objects which own the reactive primitive — the_health field in this example — retain a reference to the primitive itself and expose it publicly as a read-only reference that can be used to bind to it.
privatereadonlyAutoValue<int>_health=new(100);// private mutable viewpublicIAutoValue<int>Health=> _health;// public read-only view
Sync has a few more features — we'll document the available APIs along with tips and tricks below.
Sync is available onnuget.
dotnet add package Chickensoft.Sync
AutoValue<T> stores a single value and will broadcast it immediately to any binding callbacks at registration to keep them synchronized. Bindings are notified of any changes to the value for as long as they remain subscribed.
// hang onto the value for as long as you want to change it, then call Dispose()// when you're done with itvarautoValue=newAutoValue<Animal>(newCat("Pickles"));// hang onto the binding for as long as you want to observe, then call Dispose()// on itusingvarbinding=autoValue.Bind();// you can chain binding callback registration for ease-of-usebinding// called whenever the value changes.OnValue(animal=>Console.WriteLine($"Observing animal{animal}"))// only called for Dog values.OnValue((Dogdog)=>Console.WriteLine($"Observing dog{dog.Name}"))// only called for Cat values.OnValue((Catcat)=>Console.WriteLine($"Observing cat{cat.Name}"));autoValue.Value=newDog("Brisket");// Observing animal Brisket// Observing dog BrisketautoValue.Value=newCat("Chibi");// Observing animal Chibi// Observing cat Chibi
Note thatAutoValue<T> allows you to register type-specific callbacks for subtypes ofT (likeDog andCat above). For reference types, this makes for somevery clean code. Don't use it with value types unless you're okay with themgetting boxed.
binding// only observe dog values.OnValue((Dogdog)=>Console.WriteLine($"Observing dog{dog.Name}"))// or if you'd rather specify the type as the generic argument instead of as// the lambda argument.OnValue<Dog>(dog=>Console.WriteLine($"Observing dog{dog.Name}"))
AutoValue also allows you to provide a predicate to further customize which values you're interested in.
binding.OnValue((Dogdog)=>Console.WriteLine($"Observing dog with B name{dog.Name}"),condition: dog=>dog.Name.StartsWith('B')// customize what you care about);
AutoList<T> is a reactive wrapper aroundList<T>. Bindings will be notified of any changes to the list for as long as they remain subscribed.AutoList<T> implements the variousIList<T> interfaces, so you can generally use it just like a C# list.
varautoList=newAutoList<Animal>([newCat("Pickles"),newDog("Cookie"),newDog("Brisket"),newCat("Sven")]);usingvarbinding=autoList.Bind();binding.OnAdd(animal=>Console.WriteLine($"Animal added:{animal}"))// or with its index.OnAdd((index,animal)=>Console.WriteLine($"Animal added at index{index}:{animal}")).OnClear(()=>Console.WriteLine("List cleared"))// only called when a Dog is added.OnAdd((Dogdog)=>Console.WriteLine($"Dog added:{dog.Name}"))// only called when a Cat is removed.OnRemove((Catcat)=>Console.WriteLine($"Cat removed:{cat.Name}")).OnUpdate((previous,current)=>Console.WriteLine($"Animal updated from{previous} to{current}")).OnUpdate((Dogprevious,Dogcurrent)=>Console.WriteLine($"Dog updated from{previous.Name} to{current.Name}")).OnUpdate((Dogprevious,Catcurrent)=>Console.WriteLine($"Dog{previous.Name} replaced by Cat{current.Name}"))// or with its index.OnUpdate((Dogprevious,Catcurrent,intindex)=>Console.WriteLine($"Dog at index{index} updated from{previous} to Cat{current}"));autoList.Add(newDog("Chibi"));autoList.RemoveAt(0);
Other method overloads are available for various subtypes,and each callback can optionally receive the index of the item that was changed. You can also provide a custom comparer in the constructor.
varautoListWithComparer=newAutoList<Animal>([],newMyAnimalComparer());
Sometimes, you don't care about tracking a list of things by index.AutoSet<T> is a simple reactive wrapper aroundHashSet<T>.
Note
Due to memory allocation considerations,AutoSet<T> does not implement the fullISet<T> interfaces, which would require temporary collections to be created to track the result of batch operations.
Bindings will be notified of any changes to the set for as long as they remain subscribed.
varautoSet=newAutoSet<Animal>(newHashSet<Animal>{newCat("Pickles"),newDog("Cookie"),newDog("Brisket"),newCat("Sven")});usingvarbinding=autoSet.Bind();binding.OnAdd(animal=>Console.WriteLine($"Animal added:{animal}")).OnRemove(animal=>Console.WriteLine($"Animal removed:{animal}"))// only called when a Dog is added.OnAdd((Dogdog)=>Console.WriteLine($"Dog added:{dog.Name}"))// only called when a Cat is removed.OnRemove((Catcat)=>Console.WriteLine($"Cat removed:{cat.Name}")).OnClear(()=>Console.WriteLine("Set cleared"));autoSet.Add(newDog("Chibi"));autoSet.Remove(newCat("Pickles"));
AutoMap<TKey, TValue> is a reactive wrapper aroundDictionary<TKey, TValue>. Bindings will be notified of any changes to the dictionary for as long as they remain subscribed.AutoMap<TKey, TValue> implements the variousIDictionary<TKey, TValue> interfaces, so you can generally use it just like a C# dictionary.
varautoMap=newAutoMap<string,Animal>(newDictionary<string,Animal>{["Pickles"]=newCat("Pickles"),["Cookie"]=newDog("Cookie"),["Brisket"]=newDog("Brisket"),["Sven"]=newCat("Sven")});usingvarbinding=autoMap.Bind();binding.OnAdd((key,animal)=>Console.WriteLine($"Animal added:{key} ->{animal}")).OnRemove((key,animal)=>Console.WriteLine($"Animal removed:{key} ->{animal}")).OnUpdate((key,previous,current)=>Console.WriteLine($"Animal updated:{key} from{previous} to{current}")).OnClear(()=>Console.WriteLine("Map cleared"));autoMap["Chibi"]=newDog("Chibi");autoMap.Remove("Pickles");autoMap["Brisket"]=newPoodle("Brisket");
AutoCache is a cache which stores values separated by type. On update, it broadcasts to all bindings and stores thevalue based on the type given. This can then be retrieved by using theTryGetValue<T>(out T value) to get the last valueupdated of typeT. SinceAutoCache doesn't have a generic param, it is especially useful as a message channel, or a lookup-cachefor multiple types of data. We've optimizedAutoCache for value types so that it does not box value types on updates.You might find this pattern familiar if you've usedChickensoft.LogicBlocks.
Caution
When pushing a value of typeDog which derives fromAnimal,TryGetValue<Animal>() will not return the last valueupdated of typeDog. If you desire to get the lastAnimal value updated, you will have to useUpdate<Animal>(new Dog())instead. Although Binding notifications forOnUpdate<Dog> orOnUpdate<Animal> will still be called regardless of the type pushed.
Note
WhileAutoCache does support reference types, consider using value types instead when initializing new instances onupdate to avoid allocating unnecessary memory that would need to be immediately collected by the garbage collector.Using value types where possible helps avoid stuttering and hitches by reducing the amount of work that the garbagecollector needs to do to clean up reference types on the heap.
readonlyrecordstructUpdateName(stringDogName);varautoCache=newAutoCache();usingvarbinding=autoCache.Bind();binding.OnUpdate<UpdateName>((name)=>Console.WriteLine($"Name Updated:{name}"))autoCache.Update(newUpdateName("Pickles"));autoCache.Update(newUpdateName("Sven"));// After each update, the OnUpdate callback will be called.if(autoCache.TryGetValue<UpdateName>(outvarupdate)){// This would print out "Last received dog name: Sven"Console.WriteLine($"Last received dog name:{update.DogName}"}binding.OnUpdate<Animal>((animal)=>Console.WriteLine($"Animal Updated:{animal.Name}"));// Store and broadcast a Mouse by its less-specific supertype, AnimalautoCache.Update<Animal>(newMouse("Hamtaro"));autoCache.Update(newDog("Cookie"));autoCache.Update(newCat("Pickles"));// OnUpdate<Animal> will be called 3 times.//See the caution note above for more informationautoCache.TryGetValue<Animal>(outvaranimal)// animal will be the Mouse - HamtaroautoCache.TryGetValue<Dog>(outvardog)// animal will be the Dog - CookieautoCache.TryGetValue<Cat>(outvarcat)// animal will be the Cat - Pickles
Sync primitives are all built on top of aSyncSubject. ASyncSubject is an object which your own reactive primitive will own and use to notifySyncBindings of changes in your reactive primitive.
You will have to provide your ownSyncBinding subclass that's tailored to your reactive primitive. Bespoke bindings for each primitive are what makes Sync's API so pleasant to use, and Sync makes it really easy to create a customized binding.
Let's build our own implementation ofAutoValue<T>.
First, we'll want a read-only interface for our reactive primitive. All we need to do is inherit fromIAutoObject<TBinding>, whereTBinding is the type of binding we'll create for our AutoValue. We can stub that out, too.
publicinterfaceIAutoValue<T>:IAutoObject<AutoValue<T>.Binding>{TValue{get;}}publicsealedclassAutoValue<T>:IAutoValue<T>{publicclassBinding:SyncBinding{internalBinding(ISyncSubjectsubject):base(subject){}}}
Tip
By convention, we nest the binding in the reactive primitive class itself so that it can access private members of the primitive, as well as any of their generic type parameters.
Let's go ahead and implement the required methods for theIAutoObject interface. Luckily, we can just forward these to a privateSyncSubject which handles the deferred event loop system for us. We'll also tell our subject to perform an atomic operation whenever the value is changed, rather than mutating the state right away.
Note
Later, we'll implement a method that allows us to know when it's time to actually change the value. This is howSyncSubject is able to protect us fromreentrancy issues.
You can define an atomic operation by creating a value type struct. It's really easy to use a one-linereadonly record struct in C# for this, so that's what we'll do.
publicsealedclassAutoValue<T>:IAutoValue<T>{// Atomic operationsprivatereadonlyrecordstructUpdateOp(TValue);// ... binding classprivateT_value;privatereadonlySyncSubject_subject;publicTValue{get=>_value;set=>_subject.Perform(newUpdateOp(value));}publicAutoValue(Tvalue){_value=value;// create a new sync subject that will notify us when it's time to perform// the atomic operations we schedule_subject=newSyncSubject(this);}publicBindingBind()=>newBinding(_subject);publicvoidClearBindings()=>_subject.ClearBindings();publicvoidDispose()=>_subject.Dispose();}
To actually perform ourUpdateOp operation, we'll edit our AutoValue to implementIPerform<TOp> for every atomic operation we want to support. Our AutoValue implementation is really simple, so it's just the one atomic operation for now.
While we're at it, we'll go ahead and create abroadcast. A broadcast is also a value type that is sent to each binding. Atomic operations and broadcasts will often be identical, but not always. It's important to keep them distinct.
publicsealedclassAutoValue<T>:IAutoValue<T>,IPerform<AutoValue<T>.UpdateOp>{// Atomic operationsprivatereadonlyrecordstructUpdateOp(TValue);// BroadcastspublicreadonlyrecordstructUpdateBroadcast(TValue);// ... binding class// other members// Actually perform the atomic operationvoidIPerform<UpdateOp>.Perform(inUpdateOpop){if(_value!=op.Value){// only update if it's differentreturn;}_value=op.Value;// announce change to relevant binding callbacks_subject.Broadcast(newUpdateBroadcast(op.Value));}}
Now, the only thing left to do is make ourBinding class allow the developer to register a callback whenever the value changes.
publicsealedclassAutoValue<T>:IAutoValue<T>,IPerform<AutoValue<T>.UpdateOp>,IPerform<AutoValue<T>.SyncOp>{// Atomic operationsprivatereadonlyrecordstructUpdateOp(TValue);privatereadonlyrecordstructSyncOp(Action<T>Callback);// BroadcastspublicreadonlyrecordstructUpdateBroadcast(TValue);publicclassBinding:SyncBinding{internalBinding(ISyncSubjectsubject):base(subject){}publicBindingOnValue(Action<T>callback){AddCallback((inUpdateBroadcastbroadcast)=>callback(broadcast.Value));// invoke binding as soon as possible after it's added to give it the// current value immediately. different reactive primitives may or may not// want to do this, depending on their desired behavior._subject!.Perform(newSyncOp(callback));returnthis;// to let the developer chain callback registration}}// ... other members shown above// Perform the "sync" operation to invoke a callback with the current value// when a binding is first added. This mimics a ReactiveX BehaviorSubject.voidIPerform<SyncOp>.Perform(inSyncOpop)=>op.Callback(_value);}
Now, anyone can easily create an auto value and bind to it!
varautoValue=newAutoValue<int>(42);usingvarbinding=autoValue.Bind();binding.OnValue(value=>Console.WriteLine($"Value changed to{value}"));
Note
TheactualAutoValue<T> implementation has to account for a custom comparer, conditional bindings, and derived types, but it's otherwise almost identical.
If you're building your own reactive primitives, take a look at the full source code forAutoValue<T>,AutoList<T>,AutoSet<T>, andAutoMap<TKey, TValue> for more examples.
Sync is a generalization of the Chickensoft bindings system first seen inLogicBlocks. If you've ever used LogicBlocks, you already know how to use Sync!
Existing .NET reactive programming libraries are stunted by the reigning naming terminologies: either by trying to conform to ReactiveX's loosely defined terminology or .NET's own poorly-named observer APIs. Neither were designed with game development as the primary use case, and both result in poor code readability or correctness for many typical use cases.
Additionally,many find Rx.NET just plain confusing and difficult to deal with.
Not convinced? See how ReactiveX describes its own terminology:
Each language-specific implementation of ReactiveX has its own naming quirks. There is no canonical naming standard, though there are many commonalities between implementations.
Furthermore, some of these names have different implications in other contexts, or seem awkward in the idiom of a particular implementing language.
For example there is the onEvent naming pattern (e.g. onNext, onCompleted, onError). In some contexts such names would indicate methods by means of which event handlers are registered. In ReactiveX, however, they name the event handlers themselves. -ReactiveX Docs
Since there's "no canonical naming standard" and each implementation has "its own naming quirks",we might as well invent our own simplified terminology 🤷♀️.
Sync is pretty performant for what it does. Sync's AutoValue has been benchmarked in comparison to R3's reactive property. You cansee the benchmark source code here.
This is a bit of an apples-to-oranges comparison: Sync primitives like AutoValue protect against reentry and allows reactive subjects to define atomic operations, R3 simply invokes functions immediately every time a value changes. Naturally, R3 is about 8-9 times faster since it has essentially no overhead. Both are very fast and do not allocate memory during the hot path (the results are in nanoseconds — billionths of a second). Both scale linearly with the number of invocations, as you'd expect.
Here's the results on an M1 Max laptop:
| Method | N | Mean | Error | StdDev | Alloc |
|---|---|---|---|---|---|
| ReactiveProperty | 10 | 29.13 ns | 1.002 ns | 0.055 ns | - |
| AutoValueSet | 10 | 255.42 ns | 9.659 ns | 0.529 ns | - |
| ReactiveProperty | 100 | 298.18 ns | 20.567 ns | 1.127 ns | - |
| AutoValueSet | 100 | 2,526.14 ns | 316.602 ns | 17.354 ns | - |
| ReactiveProperty | 1000 | 2,933.28 ns | 337.410 ns | 18.495 ns | - |
| AutoValueSet | 1000 | 24,816.69 ns | 1,512.528 ns | 82.907 ns | - |
Dividing by N to get the average per property set update:
| Method | Mean |
|---|---|
| ReactiveProperty | 2.94 ns |
| AutoValueSet | 25.21 ns |
With 1,000,000,000 nanoseconds in a second, that's about340 million updates per second for R3'sReactiveProperty and40 million updates per second for Chickensoft.Sync'sAutoValue.
Or, for 16 ms frame time in a 60 FPS game, that's about 5.7 million sets per frame for R3 and 666,666 per frame for AutoValue. If you need absolute performance and no guarantees, use R3. If you need deterministic single-threaded execution, use Sync. Both are very fast and do not allocate. For UI work, which typically has latency in terms of microseconds, the choice will not matter at all.
When subscribing to changes in a reactive object, your callbacks will observe each change that the object goes through. If you try to mutate the reactive object from those callbacks, you typically want those changes to be deferred until all the callbacks for the current state of the object have finished execution.
By deferring changes, every callback is executed deterministically and in order for each state that the reactive object passes through. Deferral should still happen synchronously via a loop at the outermost stack level, but reactive programming libraries do not do this by default.
For example: the .NET Reactive Extensions (Rx.NET) do not protect against reentrancy by default unless you manuallyserialize a reactive subject (not to be confused with the other "serialization" for saving and loading). Other libraries for C#, such as the aforementionedR3 reactive programming library,do not protect against reentrancy at all, favoring absolute performance instead. Like all systems, you must evaluate the tradeoffs for your particular use case.
Note
Unless you are building absolutely massive systems, picking correctness and ergonomics over absolute performance will most likely increase the chance of success, since it makes refactoring simpler and safer.
🐣 Package generated from a 🐤 Chickensoft Template —https://chickensoft.games
About
Simple, synchronous, single-threaded reactive programming primitives and collections with fluent bindings. Sync guarantees deterministic execution and defers mutations when executing bindings, protecting your code from reentrancy issues.
Topics
Resources
License
Contributing
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Packages0
Contributors3
Uh oh!
There was an error while loading.Please reload this page.