TsvrcBehaviour
Tsvrc.Core.TsvrcBehaviour is the base class every other TsVRC runtime type extends,
directly or indirectly. It's an
UdonSharpBehaviour with structured, one-time
initialization and a small publish/subscribe event system built in. Its generated shadow
class is TsBehaviour. See How TsVRC fits together for why
your own scripts extend the shadow rather than this class directly.
Usage
In practice, you only write one method yourself: TsStart. That's where your setup code
goes. Logging and the event system (LogInfo, LogWarning, LogError, TsSubscribe,
TsEmit) are already built for you, so you just call them.
public class RoundTimer : TsBehaviour
{
protected override void TsStart()
{
LogInfo("Round timer ready.");
}
public void OnRoundEnded()
{
TsEmit("RoundEnded");
}
}
Another behaviour reacts to that event by subscribing to it, once, during its own
TsStart:
public class ScoreTracker : TsBehaviour
{
public RoundTimer roundTimer;
protected override void TsStart()
{
roundTimer.TsSubscribe(this, "RoundEnded", nameof(OnRoundEnded));
}
public void OnRoundEnded()
{
LogInfo("Recording final scores.");
}
}
Construction
A TsvrcBehaviour doesn't do anything until something calls TsConstruct on it. That
call assigns _ts and runs TsStart(), and it only ever does this once: calling
TsConstruct again on an already-constructed behaviour is a silent no-op, no matter which
overload you use. _ts is never reassigned, and TsStart never runs a second time.
That one-time guarantee is the whole point. It's what lets TsStart safely do things
that would break if run twice, like subscribing to an event or starting a timer, without
needing to guard against being re-entered. _ts is set before TsStart runs, so
TsStart can rely on it being there immediately.
There are two ways to construct a behaviour:
TsConstruct(TsRoot tsvrc)is the root form. Registering something on the Configure window's Constructs, Pools, or Factories tab ultimately generates a call to this, wiring the behaviour directly to the project's root object.TsConstruct(TsvrcBehaviour parent)is the propagating form, for a behaviour that should share an already-constructed behaviour's_tsreference instead of being wired to the root itself. It readsparent._tsand forwards to the root overload.
A few edge cases fall out of how that guard works:
- Passing a
nullroot toTsConstruct(TsRoot)is allowed._tsbecomesnull, andTsStartstill runs once. A second call, even with a real root this time, still changes nothing: the first call already used up the one construction. - Passing a
nullparent toTsConstruct(TsvrcBehaviour)throws aNullReferenceException, but only on a behaviour's first construction. The no-op guard only checks for a second call, so the first call dereferencesparent._tswithout checking it first. Once a behaviour has already been constructed, a laterTsConstruct(null)call is safe: the guard catches it beforeparentis ever touched. - Passing a real, non-null parent that was itself never constructed doesn't throw.
parent._tsis justnullin that case, and propagating a null root is fine. It's only a null parent reference that throws. - Propagation chains through any number of hops. Construct
CagainstB, which was constructed againstA, which was constructed against the real root, andCends up holding that same original root reference.
Events: TsSubscribe and TsEmit
A TsvrcBehaviour can broadcast events that other behaviours react to: one side
subscribes to a named event, the other side emits it, and every subscriber gets called.
Call TsSubscribe(listener, eventName, callbackName) to register a listener. From then
on, whenever this behaviour calls TsEmit(eventName), listener gets a
SendCustomEvent(callbackName)
call. There's no way to unsubscribe: once a listener
signs up, it stays subscribed for the rest of the world's lifetime. Pass both
eventName and callbackName through nameof() instead of a plain string, so renaming
either one later doesn't silently break the wiring.
A few things worth knowing about how subscriptions behave:
- Subscribing the same listener to the same event twice registers two separate
deliveries, not one.
TsSubscribedoesn't check for duplicates. - Listeners are called in the order they subscribed.
- Subscribing to an event from inside that same event's own callback (a reentrant
subscribe) doesn't take effect until the next time it's emitted.
TsEmitworks off a snapshot taken before it starts calling listeners, so a subscription added mid-emit can't affect the emit already in progress.
Two failure modes matter here, and they're handled differently on purpose:
- A listener that was already
nullwhen it subscribed throws aNullReferenceException, but not untilTsEmitactually reaches it. Subscribingnullitself doesn't fail; emitting to it does. Any listener earlier in the list still gets called normally before that throw happens. - A listener whose GameObject got destroyed after subscribing is treated differently:
TsEmitjust skips it, and every other listener still fires. This works because Unity's==operator treats a destroyed object as equal tonull, butTsEmitcasts toobjectfirst to check whether the reference is genuinely null (never assigned) versus just destroyed, and only throws for the first case.
One more detail, about performance rather than behavior: the storage backing
subscriptions grows by doubling instead of by one slot per subscription, so repeated
TsSubscribe calls stay cheap on average instead of reallocating every time.
Logging
Use LogInfo, LogWarning, and LogError to log from a TsvrcBehaviour subclass,
rather than calling TsvrcLogger or Debug.Log directly. See
TsvrcLogger for the full message format and how the toggles
work. Two things specific to calling it from here:
- Every message is tagged with this instance's own runtime class name, via
GetUdonTypeName(). You never pass the tag yourself. IsTsvrcInternal(falseby default) decides which of a message's paired toggles applies: the "Tsvrc Internal" one or the "Your World" one. Only override it totrueif you're writing a framework base class that should be classified as part of TsVRC itself. Every subclass of a class that overrides it inherits that classification automatically. Leave it alone in ordinary world scripts.
If you call LogInfo, LogWarning, or LogError before TsConstruct has run, or after
it ran against a root whose Log is null, these methods fall back to
Debug.Log/LogWarning/LogError directly. The tag and message format stay the same,
but there's no project prefix (there's no TsvrcLogger instance to read one from) and no
toggle filtering at all. A message logged this early always prints, no matter how
logging is configured elsewhere.
Destruction
TsDestroy() is virtual and defaults to calling Unity's own
Destroy(gameObject). In
play mode, that defers the actual removal to the end of the current frame rather than
happening instantly. Outside play mode, in the editor, Unity refuses to run it
synchronously at all. Override TsDestroy() if a behaviour needs to do something other
than remove its whole GameObject. An override replaces the default entirely: there's no
"your override runs, then the base behavior also runs" chaining, so call
Destroy(gameObject) yourself if you still need it.
What subclasses actually override
In practice, a subclass overrides TsStart() (a no-op by default) and, rarely,
IsTsvrcInternal or TsDestroy(). Everything else on this page is inherited behavior
you call, not something you override.