20
\$\begingroup\$

Interactive in-game tutorials often interrupt game flow and override standard behavior in a way that can be difficult to implement cleanly.

For example, imagine we have an action game and we want a tutorial to play when the player first takes damage:

(When player first takes damage)
[Pause Gameplay and zoom to player]
Dialog: "You just took damage!"
Dialog: "When you take damage, your health depletes:"
[Highlight health bar, animate health dropping]
Dialog: "Let's drink a potion to restore some health! Press I to open your inventory."
(Wait for player to press I)
[Show Inventory screen]
Dialog: "Click a potion to drink it!"
(Wait for player to click a potion)
Dialog: "Using a potion consumes it and restores some health."
[Remove one potion from inventory. Highlight health bar, animate health restoring]

A common way I see this implemented is by scattering conditionals throughout the code, e.g.:

public class Player : MonoBehavior {
    //...
    void OnCollisionEnter(Collision collision) {
        var damageSource = collision.gameObject.GetComponent<IDamageSource>();
        if (damageSource != null) {
            HP -= damageSource.Damage;
            var tutMan = TutorialManager.Instance;
            if (tutMan.Active && tutMan.CurrentStep == TutorialStep.WaitingForDamage) {
                tutMan.GoToStep(TutorialStep.PlayerTookDamage);
            } else {
                PlayPainSound();
            }
        }
    }
}

This approach can end up very messy, as it creates coupling, pollutes the gameplay code with tutorial logic, and adds performance overhead for checking if a tutorial is currently active.

We can reduce coupling using events, but that doesn't as easily let us skip or modify default behavior:

    void OnCollisionEnter(Collision collision) {
        var damageSource = collision.gameObject.GetComponent<IDamageSource>();
        if (damageSource != null) {
            HP -= damageSource.Damage;
            tookDamageEvent.Invoke(this, damageSource); //tutorial manager listens for this
            //we can no longer skip this code:
            PlayPainSound();
        }
    }

One potential alternative would be to create tutorial-specific components, such as TutorialPlayer, that extend the normal class and override with tutorial-specific logic where needed. However, this would require us to maintain separate prefabs with the tutorial-specific components, or be able to dynamically swap the components at runtime.

Another potential alternative is to add temporary components that have a higher execution order and block the default behavior, e.g.:

[RequireComponent(typeof(Player))]
public class TutorialPlayerCollisionDetector : MonoBehavior {
    void OnCollisionEnter(Collision collision) {
        var damageSource = collision.gameObject.GetComponent<IDamageSource>();
        if (damageSource != null) {
            var tutMan = TutorialManager.Instance;
            if (tutMan.Active && tutMan.CurrentStep == TutorialStep.WaitingForDamage) {
                GetComponent<Player>().DisableCollisions();
                tutMan.GoToStep(TutorialStep.PlayerTookDamage);
                Destroy(this);
            }
        }
    }
}

However, that approach can only interrupt Unity event functions such as OnEnable() and OnCollisionEnter(), not direct function calls.

Is there a standard pattern for implementing interactive tutorials that interrupt or replace normal game logic?

\$\endgroup\$
2
  • \$\begingroup\$ When I, as a developer of the game, can have a say in game design, this is one of the reasons why I always try to convince the designers to make the tutorial a separate level/experience. When a tutorial is living in its own scene, it's also easier to use modified prefabs and tutorial-specific components, as you mentioned. I know this doesn't answer your question, but for me, this is the preferred approach. \$\endgroup\$
    – Teo.sk
    Mar 28, 2023 at 18:37
  • \$\begingroup\$ @Teo.sk The tutorials are a separate scene/experience; however, we could potentially end up with dozens of tutorial-specific extended scripts and prefab variants, which creates its own headaches. My hope is to find a solution that minimizes the number of separate scripts and prefabs. Part of the problem is that the designer didn't start storyboarding tutorials until after main gameplay was mostly finished, and what they've been coming up with is very different than what I expected. \$\endgroup\$
    – Kevin
    Mar 28, 2023 at 19:03

2 Answers 2

28
\$\begingroup\$

The best way I know how to do this is to have a notion of an "action queue".

When you want to cause an effect that might need to be pre-empted by something else, instead of executing the effect directly in the method you're running, you add the effect onto a global action queue.

Later in the frame, the action queue can tick, running any number of action-modifiers that inspect the queued items and decide whether to re-order them, cancel them, or insert new events in front. Then it can dequeue actions and run them one at a time, until it empties the queue or it encounters an action that forces it to wait for a transition to complete or to receive UI input. The remaining actions can just sit in queue, persisting from frame to frame until their turn comes or another action invalidates them.

This is particularly powerful for things like digital trading card games where the effect of one card could trigger an ability of another card to then react to, supersede, invalidate, or modify the original card's effects - especially if those modifications aren't just numeric but could add steps to the UI flow like prompting the player to select a target.

This can keep tutorial-specific code out of the majority of your scripts entirely. Instead, they just report their actions to the queue, with whatever labels the tutorial triggers might find interesting. Then the tutorial manager can be implemented as one of the action-modifiers that processes the queue before execution: if it sees an action labelled in a way that matches one of its tutorial rules (like "action: damage response"), it can leap into action and replace that block or chain, without needing to hook event listeners into every system that feeds this queue.

\$\endgroup\$
3
  • 1
    \$\begingroup\$ I'll confess, despite the upvotes, I don't think this answer of mine is very good. It only gives a vague sketch of the approach - I'd love to see other answers that flesh out this or other alternatives in more depth. \$\endgroup\$
    – DMGregory
    Mar 24, 2023 at 15:18
  • 1
    \$\begingroup\$ Coming from hot network questions, as someone with extensive programming experience and game-playing but no game dev experience, I really enjoyed this answer, despite your reservations that it might be vague. Even if it's a sketch, it felt like a solid enough sketch to understand the concept and idea you wanted to convey, and perhaps even try to implement it. \$\endgroup\$
    – nanofarad
    Mar 24, 2023 at 20:27
  • 3
    \$\begingroup\$ I think it's a perfectly clear overview for an experienced developer; I can immediately visualize the implementation. I don't think it's the right solution for the game that prompted me to write this question, but that's not because of a lack of specific details or examples in your answer. \$\endgroup\$
    – Kevin
    Mar 24, 2023 at 22:13
11
\$\begingroup\$

If you want to completely decouple the tutorial logic from the regular game logic, then you might need to redesign your software architecture to an event-based architecture. You already seem to know how UnityEvents work, but an event-based architecture means that you use them much more extensively to separate the detection of a condition from all the different reactions to it. If you follow this paradigm thoroughly through your whole software architecture, then you gain a lot more flexibility because every separate reaction to an event is loosely coupled.

That means that when something in your game happens, like the player receives 10 damage, then you don't reduce the HP there and then. You invoke an event (tookDamagedEvent.Invoke(this, 10)). Each event can have multiple subscribers. So each reaction to the event should be a separate handler. In this case:

  • Reducing the players HP
  • Updating the health bar
  • Playing the pain sound
  • Showing the tutorial

would all be separate methods subscribed to this event.

OK, but how does that help us to change behavior at runtime?

Remember that you can also add and remove event subscriptions at runtime via tookDamagedEvent.AddListener(handlerMethod) and tookDamagedEvent.RemoveListener(handlerMethod) or activate and deactivate event listeners assigned via inspector with tookDamagedEvent.SetPersistentListenerState(index, state). And when you declare the tookDamagedEvent as public, then systems like the TutorialManager can just do so themselves without the invoking MonoBehavior having to be aware of their existence. And they can even subscribe or unsubscribe handlers belonging to other objects. So the tutorial system can also temporarily remove/deactivate the listener for the pain sound and restore it after the tutorial is over. So by turning the sound into yet another event reaction, you do enable the tutorial system to "skip or modify default behavior".

\$\endgroup\$
1
  • 2
    \$\begingroup\$ Good suggestions, thanks! I use UnityEvents quite a bit, but not as extensively as you've described. There's a bit of extra performance overhead, but the biggest downside I see is that if the architecture is primarily event based, it can make the code much harder to follow (particularly if using many persistent event listeners from the Inspector, since we can't see those if we "find all references" in our IDE). \$\endgroup\$
    – Kevin
    Mar 23, 2023 at 19:02

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .