Monday, April 7, 2025

Tryhard dev log - Cutscene and Game Scripting with Yarn Spinner v3.0 beta

In our upcoming game Tryhard, we have cutscenes and dialogue and level scripting like many other RPGs. This dev log is about how we’re implementing some of that stuff in the game. 

(Note that I'm writing this post mostly for fellow Unity game devs, but even if you don't happen to be a dev, maybe you'll appreciate this technical behind-the-scenes look anyway? Just let all the game dev words and lingo wash over you like a summer rain.)

We’re using the free open-source dialogue system plugin Yarn Spinner v3.0 beta 2 as our main scripting and story plugin. I’ve written about Yarn in the past and I'm finding this fresh new version 3 to be a great upgrade with useful features, even while in beta. For more info on these features, see the Yarn Spinner docs "Coming in v3" page.

Here’s how we’re using some new YS3 features for some cutscene and scripting stuff in Tryhard:


Tryhard editor screenshot of Yarn controlling a Cinemachine camera

We’ve been enjoying the new "async" support in Yarn Spinner v3. 

If you’re unfamiliar with async / C# tasks, it lets any method easily act as a cancelable multithreaded coroutine, even outside of a MonoBehaviour since it's a C# thing instead of a Unity thing. (It gets more complicated of course. Here's a more comprehensive intro to async. Also note that many prefer UniTask vs. Unity's Awaitables. YS3 will adapt to whatever it detects you're using.)

For Tryhard, Yarn interfaces with our CameraManager as if it were just another Yarn dialogue presenter. When the Yarn Dialogue Runner feeds it a line, it looks up which NPC is speaking and then points Cinemachine at the speaker -- which all takes place in async land:

public class CameraManager : DialoguePresenterBase {
    public override async YarnTask RunLineAsync(LocalizedLine line, LineCancellationToken token) {
        // FindSpeaker() & CinemachineLookAt() are custom, not built-in to YS
        var speaker = FindSpeaker(line.CharacterName);
        if (speaker != null)
            await CinemachineLookAt(speaker);
    }
}

Since everything is async, we can easily update this with fancier camera blocking and it'll "await" for our Cinemachine async function to finish all the same. Imagine a cinematic sweep or a slow dolly / zoom? Best of all, this will interact with all the other dialogue presenters pretty cleanly, since async lets Yarn easily track multiple dialogue presenters and pause dialogue advancement until they’re all complete, or even skip / cancel all of them gracefully. It's pretty great and it's much cleaner than sorting through the typical Unity IEnumerator coroutine soup.

But that's not the only control flow update here. Now in YS3, Yarn scripts themselves have a new “detour” command which lets you jump to a different node and then return back after the node completes. 

In practice this jump-and-return detour is basically subroutine support; now you can sort of reuse Yarn nodes as if they were mini functions.

As an example, we’re using it for scripting our game tutorials, which all require a similar setup scripting routine. Instead of copy and pasting the same setup commands at the start of every tutorial node (or coding a custom Yarn command just for tutorial setup) now we can just call <<detour TutorialSetup>> and it runs the same TutorialSetup node at the beginning of every tutorial beat:

title: Tutorial1
---
<<detour TutorialSetup>>
// TODO: do a tutorial beat
<<jump Tutorial2>>
===

title: Tutorial2
---
<<detour TutorialSetup>>
// TODO: do another tutorial beat
===

title: TutorialSetup
---
// always run before each tutorial beat 
<<cleanUp>>
<<reset>>
<<respawn Player>>
Coach: Let's try this new drill...
===

But maybe the most hotly anticipated feature in YS3 is the "storylet" support, which lets you easily write proceduralized story systems. You can succinctly write a Left 4 Dead inspired bark system where characters say their most specific line based on game state, or an 80 Days type of narrative where you get the most specific scene based on your personal game progress.

To plug your game state or progress into Yarn, you have to translate that data into Yarn variables, potentially hundreds or thousands of variables if you track every single thing a player does. To help you manage this potential variable explosion, YS3 has "smart variables" which are like variables made of variables. (Whoa!!)

slide from "Rule Databases for Contextual Dialog and Game Logic" (2012) by Elan Ruskin, an influential talk about proto-storylet bark systems in Left 4 Dead 2

Imagine you wanted to play certain dialogue if the player is winning -- you declare a smart variable called $isPlayerWinning that checks if $PlayerScore > 5 and $EnemyScore < 2 and so on. Now you use $isPlayerWinning as a convenient human readable self-documented shortcut for all that math! This setup also lets you easily refactor the math later on too.

Honestly we haven't made much use of storylets and smart variables in Tryhard yet. Maybe that'll be for a future post? We definitely want to do self-branching bark chains / impromptu conversations during the sports battles, and we expect that will rely heavily on these new storylet features. We're excited about the possibilities -- and if you've read all the way to the end of this post, surely you're excited now too.

Don't let the v3.0 beta label scare you! Unless you're about to launch a big high-stakes commercial project with zero tolerance for bugs, this beta version is probably stable enough for many devs to start using. The latest version (as of 6 April 2025) is v3.0.0-beta2 and you can read about it on GitHub. And if you hit any bugs, the best place for help is the Yarn Spinner Discord.

(Lastly, it wouldn't hurt if you wishlisted our game Tryhard on Steam... just sayin'.)