Story Coroutines: Using Synchronous Async/Await With C#
Story Coroutines: Using Synchronous Async/Await With C#

2020-07-17 c# gamedev

Sometimes, you want to pause part of your code, run other code, and then come back to where you were. While C#’s async/await functionality is intended for asynchronous background behavior, it can be leveraged to act like coroutine support, letting developers pause & resume their code. One use-case for this is implementing linear progressions, such as conversations or missions, in modern applications with event loops. async/await also allows much greater decoupling of the progession from the event loop.

The Situation

Here’s a simple example: when playing a text-based adventure, the game frequently must wait for the user to pick a choice before continuing with the adventure. This doesn’t pose a problem when writing a console application, as we can call System.Console.ReadLine() (or your environment’s equivalent) wherever we wish, and then continue with the story.

Here’s an example of how that might work:

void DoSomeEncounter()
{
    // Print some of the adventure
    Console.WriteLine("You are in a room. What do you do?");

    // We need some input!
    string input = Console.ReadLine();

    // Print more of the adventure, depending on what the user chose
    if (input == "kill")
    {
        DoKillPartOfTheAdventure();
    }
    else
    {
        DoPeacePartOfTheAdventure();
    }

    // Finish with some more of the adventure
    Console.WriteLine("You stagger out of the room, exhausted but alive.");
}

This has a couple of very useful characteristics that we might at first take for granted:

  • We can continue printing after reading the input from the user.
  • We can read input in the subroutines that are called (here, DoKillPartOfTheAdventure and DoPeacePartOfTheAdventure).
  • We can reuse the subroutines from multiple parts of the game, even if those subroutines read input (again, something that is taken for granted).
  • We are on the same thread as the user interface (even though it’s just a CLI) so we don’t need to worry about threading.

Overall, writing an adventure this way this feels fairly straightforward, like standard coding, or even storytelling. However, text-based games not using a command-line interface are obligated to keep their user interfaces responsive, which means we can’t block the main thread in the middle of a storytelling method. Indeed, how would one even get input from a GUI while blocking the UI thread? (No, using WinForms/WPF to show a modal dialog with ShowDialog() when input is needed doesn’t count.)

Attempt 1: Callbacks

One way to approach the problem could be running the story on a second thread. This would allow it to wait for input without blocking the UI thread. However, polluting the story code with threading code for exchanging state and actions with the UI thread is not elegant or a good use of developer time. This would be a workable solution, but it certainly isn’t ideal.

We can tell that introducing threading into the equation isn’t the appropriate course of action here because if we think about when the code on both the UI and story threads is running, we see that the story code never needs to be running at the same time as the UI code. Really, the story code only needs to run in short bursts - the story runs once at startup (from the beginning to the first time it needs input), and then again each time a user enters input to respond and advance the story.

OK, so running the story code at launch isn’t hard - see the Form.Load event or equivalent - and from our previous pondering, we need to also run parts of the story code when buttons are pressed. Parts of story code could run until they need user input, register a callback for when the input is received, and then return so that the UI can continue handling events. A first attempt could be swapping out event handlers on UI elements, and that could be improved by instead storing delegates. So how does that feel to use from a storytelling standpoint?

void DoSomeEncounter()
{
    // Print some of the adventure
    UI.WriteLine("You are in a room. What do you do?");

    // We need some input!
    // Stores the delegate and eventually calls it when the input has been given
    UI.GetInput(FollowUpOnEncounter);
}

void FollowUpOnEncounter(string input)
{
    // Print more of the adventure, depending on what the user chose
    if (input == "kill")
    {
        DoKillPartOfTheAdventure();
    }
    else
    {
        DoPeacePartOfTheAdventure();
    }

    // Finish with some more of the adventure
    UI.WriteLine("You stagger out of the room, exhausted but alive.");
}

Seems doable, the amount of code hasn’t exploded from a storytelling point of view, but let’s see how it compares with the original command-line storytelling:

  • We can’t continue printing or getting more input in a method after we call GetInput. Well, we could try, but any code we write after the GetInput call will still happen before the input is received, which would be out-of-order from a storytelling standpoint. I don’t think anyone wants to be forced to write their stories out of order just because their framework imposes arbitrary rules. Specifically, the only way to do things after input is received is by moving those things to the method supplied to GetInput. This might not be a dealbreaker, but it certainly makes writing substantial adventures tedious.
  • Following from the point above, we can read input in the subroutines that are called only if we haven’t already read input - and control flow has to be taken into account (i.e., no matter which path is taken, we must GetInput exactly once, including the calls in subroutines). If we try to write code after a subroutine begins to read input, the code will happen before the subroutine’s input is received. This becomes even more painful for subroutines that might want to read input a varying number of times.
  • We cannot reuse the subroutines from multiple parts of the game if those subroutines read input, because we aren’t able to store the call stack where we store the function to return to (i.e., subroutines that read input won’t be able to return to the function that called them). This could be circumvented by adding a continuation delegate to all the subroutines, but that’s an ugly hack in my opinion. (Or just a Tuesday if you’re developing for Apple operating systems…)
  • We are still on the same thread as the user interface (even though it’s just a CLI) so we don’t need to worry about threading.

This pattern of storing delegates is how the few text-based adventures I’ve seen have generally implemented things, but again it’s not ideal from the storyteller’s perspective, who likely wants to spend way more time writing content than declaring extra methods, choosing method names, debugging weird out-of-order behavior, and adding awkward code to work around the inherent limitations in the system.

Attempt 2: Putting Lipstick on a Callback

If we want to free storytellers from declaring extraneous methods, we could teach them to write anonymous methods (a.k.a lambdas, a.k.a closures) instead of declaring all their methods individually, which would make the previous example look something like this:

void DoSomeEncounter()
{
    // Print some of the adventure
    UI.WriteLine("You are in a room. What do you do?");

    // We need some input!
    // Stores the delegate and eventually calls it when the input has been given
    UI.GetInput((string input) => {
        // Print more of the adventure, depending on what the user chose
        if (input == "kill")
        {
            DoKillPartOfTheAdventure();
        }
        else
        {
            DoPeacePartOfTheAdventure();
        }

        // Finish with some more of the adventure
        UI.WriteLine("You stagger out of the room, exhausted but alive.");
    });
}

That’s a little bit better; now storytellers can break up their story code where they see fit. We still have the issue of returning from subroutines without passing continuations everywhere though, and another smaller issue: the story gets more and more indented every time it gets user input.

Attempt 3: Third Time’s the Charm

Here’s where C#’s async/await syntax comes in handy.

We don’t want to do any asynchronous I/O or threading like most async/await scenarios do, but if we implement the right interfaces, the compiler will solve both returning from a story subroutine when input is given and the indentation issues for us. It does this when it sees the await keyword by automatically generating a continuation from the rest of the method.

To access all this compiler goodness, we need a type that exposes the public INotifyCompletion GetAwaiter() method (which lets us use the await keyword on values of that type) and we need a type that has a boolean IsCompleted property, a GetResult method that returns a value or void, and implements System.Runtime.CompilerServices.INotifyCompletion to receive the continuation that the compiler generates.

Our use case is quite simple compared to what the async/await system is capable of doing, and we can create one type that satisfies both those requirements like so:

public class StoryTask : INotifyCompletion
{
    // Store the continuation so we can run it when the user gives input
    private Action Continuation { get; set; }

    // Store the user's input so the continuation can get it
    private string Input { get; set; }

    // This is called by us before we can do anything interesting, so nothing useful can go in here
    public StoryTask() { }

    // This allows us to `await` values of this type.
    // Super simple because we're using one type `StoryTask` for both the `GetAwaiter` and the Awaiter itself
    public StoryTask GetAwaiter() { return this; }

    #region Awaiter (INotifyCompletion, IsCompleted, GetResult)
    public bool IsCompleted => this.Input != null;

    // Called by compiler-generated code when this is awaited
    // (Misleading name, could be renamed SetContinuation)
    public void OnCompleted(Action continuation)
    {
        this.Continuation = continuation;
    }

    // Called by the completion when it resumes execution
    public string GetResult()
    {
        return this.Input;
    }
    #endregion

    // Our UI will use this to send the result from the user to the continuation
    public void Resume(string input)
    {
        // Store the input from the UI so the continuation can get it via `GetResult`
        this.Input = input;

        // Run the continuation
        this.Continuation();
    }
}

You may be cringing at the amount of extra code that this solution requires, but it isn’t crazy complicated and once it’s written, it never needs to be touched when writing the story code.

Let’s look at what using this in a story looks like:

async void DoSomeEncounter()
{
    // Print some of the adventure
    UI.WriteLine("You are in a room. What do you do?");

    // We need some input!
    // The `await` keyword says 'Create a continuation from the rest of this method, assign it
    // to the the StoryTask that UI.GetInput() returns using OnCompleted(), and then return back to the caller.
    // When the continuation is invoked, call `GetResult` on the `Task`'.
    string input = await UI.GetInput();

    // Print more of the adventure, depending on what the user chose
    if (input == "kill")
    {
        await DoKillPartOfTheAdventure();
    }
    else
    {
        await DoPeacePartOfTheAdventure();
    }

    // Finish with some more of the adventure
    UI.WriteLine("You stagger out of the room, exhausted but alive.");
}

Incredible - it looks almost exactly like the original command-line version, from the story author’s point of view!

It also meets our requirements quite nicely:

  • We can continue printing after reading the input from the user, and we can read input multiple times, however we want! The compiler takes care of generating the correct completion to make sure all forms of control flow behave as expected!
  • We can read input in the subroutines that are called (here, DoKillPartOfTheAdventure and DoPeacePartOfTheAdventure), so long as we mark them as async.
  • We can reuse subroutines in multiple parts of the game, even if those subroutines read input, and when user input is given, they all return to the appropriate methods that called them.
  • We are on the same thread as the user interface so we don’t need to worry about threading.
  • No extra indentation accumulating below input reads!

Putting it All Together

The last parts of the puzzle are the UI methods, which I haven’t shown thus far.

Below is the general idea for a UI implementation using a WPF user interface.

This assumes you have a TextBlock called OutputTextBlock for showing text, a Button called SubmitButton for submitting the user input, and a TextBox named InputTextBox for the user to enter their input into.

public class StoryWindow : Window
{
    // Store the task that's currently awaiting user response
    private StoryTask PendingTask = null;

    // This implements the `UI.WriteLine()` method used in the example story code
    void WriteLine(string text)
    {
        OutputTextBlock.Text += text + "\n";
    }

    // This implements the `UI.GetInput()` method used in the example story code
    StoryTask GetInput()
    {
        // Clear the existing text in the input text box
        InputTextBox.Text = "";

        // Store and return the task that should receive the user's response
        this.PendingTask = new StoryTask();
        return this.PendingTask

        // After we have returned the task, the continuation will be assigned to the new StoryTask via its OnCompleted()
    }

    // Event handler assigned in the window's XAML
    private void SubmitButton_Click(object sender, RoutedEventArgs e)
    {
        // Send the current text in the input text box to the pending task, by running its continuation
        // The story's execution is synchronous, on the UI thread, inside this call to `Resume`.
        this.PendingTask.Resume(InputTextBox.Text);
    }
}

And there we have it - we’re using C#’s async/await functionality not to do multiple things at once, but to store continuations elegantly coroutine-style to call later from the UI.

Considerations for a more robust implementation:

  • StoryTask having its Resume() method available publically (it’s even visible to the story code that’s creating the task!) doesn’t seem ideal - the UI should be the only one who can resume the task. My own implementation separates out different subclasses of an abstract class StoryTask (e.g. WPFStoryTask, ConsoleStoryTask), which both hides the Resume() method by moving it into the subclasses and allows the StoryTask to not care whether it’s a blocking (command-line) or non-blocking (GUI) approach to getting the user input. Specifically, blocking StoryTask implementations need to call their continuation as soon as they receive it in OnCompleted(), while non-blocking tasks instead save a reference to the continuation, and are remembered by the UI to be invoked when the user supplies input.
  • StoryTask could be generalized to store any kind of user input using generics (e.g. StoryTask<TResult>). This could add a little bit of extra type-safety if you modify GetInput() to present a menu of options, and you want to use different menu option subclasses in different calls to GetInput().