Getting Started with Unika
February 19, 2025 · View on GitHub
Unika’s scripting solution may be intimidating if you simply look at the source code. However, there are only a few small snippets you need to remember to be effective. This guide will present you with all the basics you care about, and then leave it up to you to experiment.
Pre-Defined Interfaces
Unika defines a lot of interfaces. Most of these interfaces are used to define extension methods that round out Unika’s API and reduce boilerplate in your code. With that said, it is easy to get lost when searching for interfaces and types. There are only 7 interfaces your types ever need to implement:
- IUnikaScript
- IUnikaInterface
- IScriptFilter
- IScriptResolver
- ICachedScriptResolver
- ScriptTypeExtraction.IReceiver
- ScriptTypeExtraction.ITypeReceiver
Of these, all but the first 2 are for niche use cases. You can mostly ignore them. Any other interface you encounter will either be defined by Unika types or automatically added to your types via source generators. Such interfaces are used by extension methods.
Creating Your First Script
If you haven’t done so already, in the Assets menu, create a new Latios Bootstrap appropriate for your project. The bootstrap will install some systems to handle entity serialization for us. These systems aren’t always right for every project, but they will be sufficient for our purposes.
Now that you’ve done that, in the Assets menu, create a Unika Script and name it
SayScript. The template will generate two types named SayScript, but placed
in two separate namespaces named Authoring and Scripts respectively. Both
types are declared partial, because both are processed by source generators.
These technically do not need to be in the same file, and defining the authoring
type isn’t a hard requirement.
In the editor, create a subscene. Then add a Game Object to it. Attach your
newly-defined SayScript to the Game Object. When you do this, you should see a
Script Buffer (Unika) was added as well. That is what will allow the entity to
hold scripts.
Let’s add a method to our script. Add this to the SayScript in the Scripts
namespace.
public void Say()
{
UnityEngine.Debug.Log("Hello. This is Unika speaking.");
}
Running Your Script
Next, let’s create a system that schedules an IJobEntity job. The Execute()
method should ask for Entity and a ref DynamicBuffer<UnikaScripts>.
Next, we want to iterate over all scripts in the buffer. However, it is
currently in the wrong form. To get it into the correct form, we need to call
AllScripts() on it and pass in our entity. This creates an
EntityScriptCollection, which is the main way to navigate and index scripts on
an entity.
EntityScriptCollection is something we can iterate in a foreach. However,
this gives us type-agnostic Script instances. If we want our SayScript, we
need to cast the Script to a Script<SayScript>. We can do this via the
extension method TryCastScript<SayScript>() which returns a bool if our cast
was successful and provides the result in an out parameter.
This is boilerplate-heavy, and is also not the most performant. Instead, we can
ask EntityScriptCollection to provide a filtered list of scripts that are of
our requested type. From the EntityScriptCollection, we can call
OfType<SayScript>(). This is also something we can iterate in a foreach, but
now we get Script<SayScript> results.
Script<SayScript>.valueRW provides a reference to the actual SayScript
instance. We can now call our Say() method. Here’s our full job:
[BurstCompile]
partial struct UnikaJob : IJobEntity
{
public void Execute(Entity entity, ref DynamicBuffer<UnikaScripts> scriptsBuffer)
{
foreach (var sayScript in scriptsBuffer.AllScripts(entity).OfType<SayScript>())
{
sayScript.valueRW.Say();
}
}
}
You can schedule this job either single-threaded, or in parallel. It should work just fine. Try it out by entering play mode!
The final code is very simple, but there were a lot of details we covered. The
key points are that we get an EntityScriptCollection from calling
AllScripts(), and this gives us access to all the scripts on the entity. These
come in the form of a generic handle called Script, and for instances that are
SayScript, we need to convert the handle into a Script<SayScript> so that we
can access the actual instance. We can cast and check, or we can use an
OfType<SayScript>() filter. Spend some time here to play around with the code
and ensure you understand these concepts, because we will continue to build on
them.
Referencing Another Script
Let’s create another script named RespondScript. This time, we will give it a
method Respond() like this:
public partial struct RespondScript : IUnikaScript
{
public void Respond()
{
UnityEngine.Debug.Log("Hi Unika. Nice to meet you.");
}
}
Add this script to the same Game Object we added SayScript to.
Now, we want SayScript to reference a RespondScript and call Respond(). To
reference another script, we can use a ScriptRef. This references any script.
To specifically specify we want a RespondScript, we use
ScriptRef<RespondScript>.
Script<T> is kinda like RefRW<T>, in that it can be invalidated by
structural changes or job dependencies. That’s why we use
ScriptRef<RespondScript>, which is serializable. But we must convert our
ScriptRef<RespondScript> into a Script<RespondScript> when we want to call
Respond(). This process of converting a ScriptRef into a Script is called
resolving.
There are two ways to resolve a ScriptRef. If we know the entity the
ScriptRef belongs to (a property of ScriptRef we can read), then we can use
the EntityScriptCollection of that entity to resolve the reference and get a
Script. The other option is to use a dedicated resolver, such as a
LookupScriptResolver. We pass either of these into the Resolve() or
TryResolve() methods of our ScriptRef. The former Resolve() method will
throw an exception if it fails.
In our case, we know the script belongs to the same entity, so we will pass in
the EntityScriptCollection. But actually, we can instead pass in the
Script<SayScript>. This has the property allScripts which is the same
EntityScriptCollection. The reason to prefer Script<SayScript> is that it
also contains the script’s metadata, which we’ll cover later.
Here’s what SayScript looks like now:
public partial struct SayScript : IUnikaScript
{
public ScriptRef<RespondScript> respondScript;
public void Say(Script<SayScript> me)
{
UnityEngine.Debug.Log("Hello. This is Unika speaking.");
respondScript.Resolve(me.allScripts).valueRW.Respond();
}
}
Back in Authoring, we can simply define a reference to a RespondScript as a
public or serialized field. Because we are in authoring, this will reference the
authoring MonoBehaviour for RespondScript. When we create the
Scripts.SayScript in Bake(), we can simply call GetScriptRef() on our
authoring RespondScript and pass in the IBaker. Note that we do not need to
call DependsOn() here, as GetScriptRef() will register all required baking
dependencies for us.
Because we used the EntityScriptCollection to resolve our ScriptRef on the
same entity, we can safely schedule our job in parallel. If we had used a
LookupScriptResolver, we’d get safety errors. However, a
LookupScriptResolver in a single-threaded job would allow us to reference a
RespondScript on a different entity in our scene. Feel free to give that a
try!
Resolving ScriptRef Mutates
ScriptRef contains a cache about the whereabouts of the script it references
inside the buffer. If scripts were added to or removed from the target entity at
runtime, this cache can get out of date, and Unika will have to do a slower
search to find the script. When this slower search happens, the ScriptRef
cache will be updated to the new location.
This can sometimes be problematic, especially if the ScriptRef comes from an
in parameter or some other readonly context. To get around this limitation,
copy the ScriptRef to a local variable, then resolve the local version. This
won’t update the original cache, but it will circumvent the compiler errors.
Caches remain valid through serialization and instantiation. If you do not add
or remove scripts at runtime, then the cache will always have the right
location. That means even your first resolve of a ScriptRef may still have an
up-to-date cache.
Assignments and Comparisons
So far, we have shown conversion from Script to Script<T> and from
ScriptRef<T> to Script<T>. But what about going the other direction?
Script<T> can be assigned to any Script or ScriptRef<T>. And any of those
three can be assigned to a ScriptRef.
All these types support various equality tests against each other and can all be
used in hashmaps. Additionally, they all define a Null instance and all expose
an entity property.
Time to Polymorph
So far, everything we did had a hard requirement of knowing the exact script type to work with it. That’s far from flexible, and doesn’t really add much over what we can do with vanilla ECS.
But what if instead of searching for scripts of a specific type, we searched for scripts that implemented that interface? And then what if we could call those interface methods without ever knowing what type the script actually was?
We’re going to do just that.
Let’s define the following interface:
partial interface ISay : IUnikaInterface
{
void Say(ISay.Interface self);
}
Alright. There’s already a lot to unpack here. The first thing to note is that
we have a partial interface. That means this interface is being expanded upon
by source generators. That’s also why this interface inherits the
IUnikaInterface.
There’s also ISay.Interface being passed as a parameter in the Say()
definition. Interface is a type created by the source generator. Think of it
like the interface equivalent to Script<T> in that Interface is a handle to
a script that implements the interface. As you might expect, the source
generator also created ISay.InterfaceRef.
Let’s modify SayScript to implement ISay. ISay.Interface also has the
allScripts property, so we don’t need to modify the body of Say() at all.
In our job, we want to filter our scripts to the ones implementing ISay. We do
that using Of<ISay.Interface>(). This is a really important point to keep in
mind. When casting or filtering for an interface, we always use the generated
Interface as the generic argument.
Now we have an ISay.Interface instance in our foreach. And from it, we want to
call our Say() method. ISay.Interface implements ISay, so we can just do
that.
foreach (var sayInterface in scriptsBuffer.AllScripts(entity).Of<ISay.Interface>())
{
sayInterface.Say(sayInterface);
}
Our job is no longer referencing any explicit script types from the Scripts
namespace. That means we can add all sorts of new scripts that implement ISay,
each doing their own thing. And our job won’t have to be updated at all to work
with them.
IUnikaInterface Capabilities and Limitations
At this point, you might have a lot of questions about what you are allowed to do. You can do a lot.
IUnikaInterface supports multiple methods, properties, and indexers. For
methods and indexers, you can pass up to 7 parameters (let me know if you need
more). It supports in, ref, and out parameters as well as ref and ref readonly return values. However, ref struct parameters or return values are
NOT supported.
IUnikaInterface is allowed to inherit other interfaces, and all of those
interface methods will also be supported polymorphically. Scripts are also
allowed to implement multiple different interfaces of type IUnikaInterface.
Converting a Script<T> to an Interface is not implicit, but there should be
an extension method generated called ToInterface() which produces a temporary
type assignable to any Interface the script implements.
In authoring, you can get an InterfaceRef by any authoring component that
implements IUnikaInterfaceAuthoring<T> where T is the InterfaceRef type
you care about. The implementation for this is explicit, so you will need to
cast the authoring component to a IUnikaInterfaceAuthoring interface instance
to call that method.
Script Metadata
Let’s imagine we have an interface named IOnCollision, and we have an entity
we need to invoke that interface on for all scripts implementing that interface.
However, we have 30 scripts on that entity, and only two of them implement that
interface. In addition, some of the scripts have a lot of data stored in them.
Traversing the entire scripts buffer to find the 2 scripts we care about would
be extremely wasteful of memory bandwidth. How does Unika deal with this?
The answer is that Unika divides up the scripts buffer into three separate regions:
- Master Header
- Script Headers
- Script Instance Data
The Master Header contains special bookkeeping data such as the number of scripts as well as a filter mask for quickly ruling out an entire buffer for script and interface searches.
Script Headers contain the metadata such as what type each script is, and where
its instance data is located. They also contain a filter mask for quickly ruling
out implemented interfaces. Thus, when searching for scripts that implement
IOnCollision, Unika reads the Master Header, and then searches the Script
Headers. This greatly reduces the amount of memory Unika must tread through
during its search.
Now let’s assume that we instead have an IUpdate interface, and we wanted the
concept of scripts being “enabled”. Perhaps lots of scripts implement the
IUpdate interface, but only a small number are enabled at a time. Making a
virtual call to check if each script is enabled would be very expensive. We can
do better.
Each Script Header stores several user values which you can use for whatever
purpose you want. They are named userFlagA, userFlagB, and userByte. The
first two are bool values (bit-packed in the header) while the last is a full
byte which could be used to store enumerations or counters. These values are
provided to you to read or modify by any Script, Script<T>, or Interface.
You may have also seen in the Authoring MonoBehaviour where these values can
be initialized during baking.
You can incorporate these values into your foreach statement when searching for
script types or interfaces. You do this using the Where() method. You can
chain up to 8 Where() methods at a time. Each Where() method requires an
IScriptFilter. You can find a prebuilt collection of filters in the static
ScriptFilter class.
If we treated userFlagA as the enabled state, we could write our search like
this:
foreach (var needsUpdate in scriptsBuffer.AllScripts(entity).Of<IUpdate.Interface>().Where(ScriptFilter.UserFlagATrue))
Warning: If you create your own custom IScriptFilter, Unika will sometimes
provide it false positives for whatever initial type or interface it is
searching for. Filters run before the final matching step for performance
reasons.
Entity Serialization
By default, Unika handles serialization of most types at baking time and
deserialization within a subscene ProcessAfterLoad system. However, because
entities can be remapped during instantiation, special care is needed to ensure
this remapping happens correctly.
The default systems we enabled in the bootstrap assume that all entities are
instantiated or loaded during InitializationSystemGroup. Therefore, one system
at the beginning of InitializationSystemGroup is responsible for serializing
any entity with the UnikaEntitySerializationController component enabled (it
is an IEnableableComponent). You must enable this component for any entity you
wish to instantiate if any of the script’s references to entities (including
ScriptRef types) may have changed since the last serialization or load. It
will be disabled again by the deserialization system which updates in
PostSyncPointGroup.
If you only ever instantiate entities with scripts from prefabs, you may not need to touch serialization.
If the built-in entity serialization systems do not match your needs, remove
Unika from the bootstraps and look at the ScriptSerialization static class to
drive entity serialization. You might also be interested in that class if you
need to perform saving or network synchronization.
Unika in Projects
Now that we’ve covered the various essentials to Unika, you might be wondering how all this might fit into the grander ECS spectrum. I’ll provide some hints and ideas here, but it will be up to you to decide if these are good ideas for your project.
Data Access
An important detail to keep in mind is that unlike MonoBehaviours which in
some way or another can access just about anything, Unika scripts are limited by
what is provided through the interfaces. If you never give scripts access to any
ECS data, scripts will never be able to modify ECS components. If you only ever
give access to a handful of component types, then scripts can only interact with
those component types. Passing in an EntityManager or an EntityCommandBuffer
will give scripts free reign over whatever entities they can find. What is
exposed and what is hidden from scripts should be an intentional choice based on
your project and team.
From a practical standpoint, many projects may wish to create context structs which contain a Component Broker, game time values, various key entity references, perhaps a command buffer, and maybe even the archetypes array for Temp Query.
Sparse Logic Updates
One area Unika is very good at is running one-off logic segments efficiently. Rather than scheduling a job for each piece of logic, Unika allows many different pieces of logic to all execute within the same job. It is often a good idea to run a single-threaded Unika job alongside a heavy computation job such as pathfinding.
Good candidates for sparse logic can be things involving player handling, especially if the player character is composed of a hierarchy. Camera, UI, game rules, boss logic, and global event managers may all be good candidates.
Unika is also good at events that happens sparsely, especially if such events may need to be handled one of many ways. An example of this is an interaction system in an RPG. While an ECS system may perform the logic of detecting that an interactable should be interacted with, it may be more efficient to let Unika handle the interaction rather than schedule a job for each possible handling logic.
Modular Sequencing
With polymorphism, Unika can be quite effective at handling modular sequenced logic, such as tween sequences, state machines, behavior trees, and instruction queues. While such problems can usually be solved in ECS, making them efficient can prove challenging. Most of these problems don’t often need “absolute performance”. A modestly-performing solution off the main thread is often good enough and can save months of development time and frustration. With jobs and Burst, Unika provides significantly better performance than traditional Unity OOP.
Don’t forget that ScriptRef and InterfaceRef can be stored in ECS
components, buffers, and other collections.
Perfect Moment Execution
There are times when it can be much more efficient to process things “in the moment” rather than defer to a later point. A good example of this is collision and contact handling events. In Unity Physics, collisions and contacts need to be dumped into buffers, and then scanned by subsequent jobs for any logic that needs to modify it. Much of the saved data may actually be for pair combinations the special logic doesn’t actually care about. This is very hard on memory, and is somewhat painful to make efficient. With Unika, scripts can evaluate collision and contacts right when they are computed and fresh on the stack. No more scanning, and no more just-in-case memory usage.
Scripting
Well, yeah. The very first sentence of this guide described Unika as a “scripting solution”. But what does that actually mean?
It generally means that once you have a few ECS systems to drive Unika, scripts can be much faster to prototype and iterate with. And they are also friendlier to tech artists or those who struggle with DOTS.
Advanced APIs
Unika contains a few advanced APIs when you need a little more power. They are discussed briefly here to make you aware of them. But make sure to read the XML docs to understand how to use them.
Multi-Script Baking
UnikaMultiScriptAuthoring is an abstract class which you can inherit instead
of using the authoring component from the template. This authoring component
lets you set up multiple scripts at once dynamically that may reference each
other. You might choose to use this API when baking complex configurations like
state machines or graphs from a ScriptableObject, and the number and types of
scripts are somewhat dynamic.
There are also extra authoring extension APIs. IBaker.AddScript() can add a
script to any UnikaScriptBufferAuthoring including on different GameObjects
from what is currently being baked.
EntityManager.GetBakedScriptInPostProcess() allows you to patch scripts that
needed blobs computed via smart blobbers before they get merged into the script
buffer.
And IBaker.CreateScriptBuffersForAdditionalEntity() lets you set up scripts
for an entity created entirely by the baker. In this case, you get the
DynamicBuffer<UnikaScripts> directly and can use the runtime APIs to populate
it.
Runtime Adding and Removing of Scripts
The static ScriptStructuralChange class allows for adding and removing a
script to the DynamicBuffer<UnikaScripts> at runtime. Be very careful though,
because if you call this from inside a script on the same buffer the script
lives in, you can corrupt your memory and even crash Unity.
Script Casting Generically
Many of the extension methods for casting and converting various script handles
can be found in the static ScriptCast class. You can also use these methods
directly in your own generic code if the need arises.
Script Reflection
The static class ScriptTypeExtraction provides various managed (not
Burst-compatible) utilities for converting a Script or a System.Type to a
concrete generic that can be forwarded back to your own logic. You might use
this for custom editor tooling or debugging where you want the specific script
type details for every instance in a scripts buffer.