Executing scripts in games

Guy is right: This is a long blog post. There'll be much text, some code and a few runnable examples. I'll try my best to keep it interesting, but let's face it, this is DRY. The full code of the example above can be found at the very end of this post.

If you want to know HOW to make it easy to write scripts (as in movie scripts, not programming scripts) or tutorial flows, this is hopefully going to be interesting for you.

Scripting usually refers to a scripting language that is used to control the game logic, but just to be clear, in this case, I refer to a script as in a movie or play script.

Scripts are needed for several parts in a game and in my experience, a good approach to write these scripts can make huge difference how the development plays out. When we define a script, we want to achieve several things:

The last point is in my experience achieved, when the first three points are met. When writing is easy, you can do it fast. When it's easy to understand, you can quickly see how it works. When it's quick to test, you can quickly see if it works and thus can iterate when you realize where you want to refine it.

Appliances

In the context of a game, a script can be used for several things. Here are a few examples:

Structure

I've used the scripting approach primarily for tutorials and non-interactive dialogue sequences and have implemented in in different programming languages and engines.

After a few iterations, a structure has emerged that I found to match the criterias I mentioned above. Here's a quick rundown of the structure:

There are different ways to implement these parts; probably the easiest variant is the immediate mode variant, which is very similar to how immediate GUI systems work. It is however possible to apply the same principles to retained mode systems as well. I have used both variants, but we will focus on the immediate mode variant here since it's simpler to implement and understand. I will later lay out the differences and which problems arise when using one or the other.

Let's get started

The most simple variant of this pattern looks like this:

  1 #include "raylib.h"
  2 
  3 int script(int step) {
  4     int nextStep = step;
  5     if (step == 0) {
  6         DrawText(
  7             "This is the first message.\n"
  8             "Click to continue.", 
  9             5, 5, 20, WHITE);
 10 
 11         if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
 12             nextStep = 1;
 13         }
 14     }
 15     
 16     if (step >= 1 && step <= 2)
 17     {
 18         DrawText(TextFormat(
 19             "This is the second message,\n"
 20             "it is displayed for 2 steps.\n"
 21             "The current step is %d.", step), 
 22             5, 50, 20, WHITE);
 23 
 24         if (step == 1 && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
 25             nextStep = 2;
 26         }
 27     }
 28 
 29     if (step == 2) {
 30         DrawText(
 31             "This is the last message.\n"
 32             "Click again!", 5, 5, 20, WHITE);
 33 
 34         if (IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) {
 35             nextStep = 3;
 36         }
 37     }
 38     if (step == 3) {
 39         DrawText("Goodbye!", 100, 100, 20, WHITE);
 40     }
 41     return nextStep;
 42 }
 43 
 44 #ifdef PLATFORM_WEB
 45 #include <emscripten.h>
 46 EMSCRIPTEN_RESULT emscripten_get_element_css_size(const char * target, double * width, double * height);
 47 #endif
 48 
 49 int main(void)
 50 {
 51     int screenWidth = 600, screenHeight = 240;
 52 #ifdef PLATFORM_WEB
 53     // needed to adapt to the dynamic size of the canvas element in browser
 54     double canvasWidth, canvasHeight;
 55     emscripten_get_element_css_size("#sample_1", &canvasWidth, &canvasHeight);
 56     screenWidth = (int)canvasWidth;
 57     screenHeight = (int)canvasHeight;
 58 #endif
 59     InitWindow(screenWidth, screenHeight, "Scripting Example");
 60     SetTargetFPS(30);
 61     
 62     // our step counter
 63     int step = 0;
 64 
 65     while (!WindowShouldClose())
 66     {
 67         BeginDrawing();
 68         ClearBackground(DARKBLUE);
 69         // running our "script"
 70         step = script(step);
 71         EndDrawing();
 72     }
 73 
 74     CloseWindow();
 75 
 76     return 0;
 77 }

The compiled program works like this:

This is a very simple script that displays three messages in sequence. The script is controlled by the variable `step` that is passed to the function. The function returns the next step that should be executed the next time the function is called. It is important not to modify the step variable during the script execution itself, as this would make followup checks against the step variable unreliable.

This approach is very simple and in principle easy to write and to understand, but it suffers from several drawbacks:

Let's see how we can improve this script by refactoring it a little:

  1 #include "raylib.h"
  2 static void messageAction(int stepStart, int stepEnd, int *nextStep, const char* message, 
  3     int x, int y, int currentStep, int jumpToStep)
  4 {
  5     if (currentStep < stepStart || currentStep > stepEnd) 
  6     {
  7         return;
  8     }
  9     
 10     DrawText(message, x, y, 20, WHITE);
 11     // optional condition to proceed to next step - if start step is the current step
 12     if (jumpToStep >= 0 && currentStep == stepStart && 
 13         IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) {
 14         *nextStep = jumpToStep;
 15     }
 16 }
 17 
 18 int script(int step) {
 19     int nextStep = step;
 20     int scriptStep = 0;
 21 
 22     messageAction(scriptStep, scriptStep, &nextStep,
 23         "This is the first message.\n Click to continue.", 
 24         5, 5, step, 1);
 25 
 26     scriptStep++;
 27     messageAction(scriptStep, scriptStep + 1, &nextStep,
 28         TextFormat(
 29             "This is the second message,\n"
 30             "it is displayed for 2 steps.\n"
 31             "The current step is %d.", step), 
 32         5, 80, step, 2);
 33     
 34     scriptStep++;
 35     messageAction(scriptStep, scriptStep, &nextStep,
 36         "This is the last message.\nClick to continue.", 
 37         5, 5, step, 3);
 38     
 39     scriptStep++;
 40     messageAction(scriptStep, scriptStep, &nextStep,
 41         "Goodbye!", 
 42         100, 100, step, -1);
 43     
 44     return nextStep;
 45 }
 46 
 47 #ifdef PLATFORM_WEB
 48 #include <emscripten.h>
 49 EMSCRIPTEN_RESULT emscripten_get_element_css_size(const char * target, double * width, double * height);
 50 #endif
 51 
 52 int main(void)
 53 {
 54     int screenWidth = 600, screenHeight = 240;
 55 #ifdef PLATFORM_WEB
 56     // needed to adapt to the dynamic size of the canvas element in browser
 57     double canvasWidth, canvasHeight;
 58     emscripten_get_element_css_size("#sample_2", &canvasWidth, &canvasHeight);
 59     screenWidth = (int)canvasWidth;
 60     screenHeight = (int)canvasHeight;
 61 #endif
 62     InitWindow(screenWidth, screenHeight, "Scripting Example");
 63     SetTargetFPS(30);
 64     
 65     // our step counter
 66     int step = 0;
 67 
 68     while (!WindowShouldClose())
 69     {
 70         BeginDrawing();
 71         ClearBackground(DARKBLUE);
 72         // running our "script"
 73         step = script(step);
 74         EndDrawing();
 75     }
 76 
 77     CloseWindow();
 78 
 79     return 0;
 80 }

By using a dedicated function that does the action and condition check how to proceed, the code is now much more condensed: 3 steps, 3 instruction blocks. Fairly easy to read and understand.

The absolute numbers have been replaced by a scriptStep variable that is increased for each step. This makes it easier to insert new steps or to change the order of steps.

Implementing a system

By organizing the code, we can tell a whole story this way. The following code files are used to create the initial example at the top of this post:

  • 💾
  1 #include "sample-3.h"
  2 
  3 // This is the script that we are running.
  4 // The update is called every frame.
  5 // The conditions and actions run selectively based on the current step.
  6 
  7 void ScriptUpdate() {
  8     // to avoid using absolute script step numbers, we count scriptStep
  9     // up with each step and use it as the script's step number.
 10     int scriptStep = 0;
 11 
 12     // before we begin, as our 1st instruction, we wait for user input
 13     ConditionWaitForMouseClick(scriptStep, scriptStep + 1);
 14     
 15     // Our second instruction is to move guy to a coordinate
 16     scriptStep++;
 17     ConditionWaitForGuyPosition(scriptStep, scriptStep + 1, 220, 100);
 18 
 19     // We introduce Guy to the user
 20     scriptStep++;
 21     ActionDrawBubble(scriptStep, -1, "Hi, I am Guy.", 220, 65);
 22     ConditionWaitForMouseClick(scriptStep, scriptStep + 1);
 23 
 24     // A few more sentences by guy...
 25     scriptStep++;
 26     ActionDrawBubble(scriptStep, scriptStep + 1, "This blog entry is going to be long...", 220, 65);
 27     ConditionWaitForMouseClick(scriptStep, scriptStep + 1);
 28 
 29     scriptStep++;
 30     ActionDrawBubble(scriptStep, -1, "I told Zet that no one will read that much.", 220, 65);
 31     ConditionWaitForMouseClick(scriptStep, scriptStep + 1);
 32 
 33     scriptStep++;
 34     ActionDrawBubble(scriptStep, -1, "But he insists there's no way around it.", 220, 65);
 35     ConditionWaitForMouseClick(scriptStep, scriptStep + 1);
 36 
 37     scriptStep++;
 38     ActionDrawBubble(scriptStep, -1, "So I will leave through my magic door....", 220, 65);
 39     // let's draw a closed doof for the next 3 script steps
 40     ActionDrawSprite(scriptStep, scriptStep + 3, doorClosed, 300, 100);
 41     ConditionWaitForMouseClick(scriptStep, scriptStep + 1);
 42 
 43     scriptStep++;
 44     ConditionWaitForGuyPosition(scriptStep, scriptStep + 1, 280, 100);
 45 
 46     scriptStep++;
 47     ConditionWaitForSeconds(scriptStep, 1.0f);
 48 
 49     scriptStep++;
 50     // replace the open door sprite with the closed one for another 3 steps
 51     ActionDrawSprite(scriptStep, scriptStep + 3, doorOpened, 300, 100);
 52     ConditionWaitForSeconds(scriptStep, 0.5f);
 53     ActionDrawBubble(scriptStep, scriptStep + 2, "Have fun!", 300, 65);
 54 
 55     scriptStep++;
 56     ConditionWaitForGuyPosition(scriptStep, scriptStep + 1, 298, 100);
 57 
 58     scriptStep++;
 59     ConditionWaitForSeconds(scriptStep, 0.5f);
 60 
 61     scriptStep++;
 62     ActionDrawSprite(scriptStep, scriptStep + 1, doorClosed, 300, 100);
 63 }
  1 #include "sample-3.h"
  2 
  3 // This file contains the various conditions and actions that can be used in the script.
  4 // Using the currently active step, the conditions and actions are only executed,
  5 // if the current step is within the specified range.
  6 
  7 // This function determines, if based on the current step, the specified range is active.
  8 // If the end step is -1, only the start step is checked.
  9 static int IsStepActive(int stepStart, int stepEnd) 
 10 {
 11     if (stepEnd < 0) return scriptContext.currentStep == stepStart;
 12     return scriptContext.currentStep >= stepStart && scriptContext.currentStep <= stepEnd;
 13 }
 14 
 15 // This function draws a speech bubble with a message at the specified position.
 16 void ActionDrawBubble(int stepStart, int stepEnd, const char* message, int x, int y)
 17 {
 18     if (!IsStepActive(stepStart, stepEnd)) {
 19         return;
 20     }
 21     
 22     int textWidth = MeasureText(message, 20);
 23     int textHeight = 20;
 24     int width = textWidth + 10;
 25     int height = textHeight + 10;
 26     x = x - width / 2;
 27     DrawRectangle(x, y, width, height, WHITE);
 28     DrawRectangleLinesEx((Rectangle){x, y, width, height}, 2, BLACK);
 29     DrawText(message, x + 5, y + 5, 20, BLACK);
 30 }
 31 
 32 // This function waits for a mouse click to continue to the next step.
 33 void ConditionWaitForMouseClick(int stepStart, int jumpToStep) {
 34     if (!IsStepActive(stepStart, -1)) {
 35         return;
 36     }
 37 
 38     const char *message = "Click to continue ...";
 39     int textWidth = MeasureText(message, 20);
 40     char animatedMessage[64] = {0};
 41     int len;
 42     for (len = 0; message[len]; len++) animatedMessage[len] = message[len];
 43     int dotPos = (int)(GetTime() * 5) % 4;
 44     animatedMessage[len - 1] = dotPos == 2 ? ':' : '.';
 45     animatedMessage[len - 2] = dotPos == 1 ? ':' : '.';
 46     animatedMessage[len - 3] = dotPos == 0 ? ':' : '.';
 47     DrawText(animatedMessage, GetScreenWidth() - textWidth - 10, GetScreenHeight() - 30, 20, WHITE);
 48     
 49     if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) {
 50         scriptContext.nextStep = jumpToStep;
 51     }
 52 }
 53 
 54 // This function draws a sprite at the specified position.
 55 void ActionDrawSprite(int stepStart, int stepEnd, Texture2D sprite, int x, int y) {
 56     if (!IsStepActive(stepStart, stepEnd)) {
 57         return;
 58     }
 59 
 60     DrawTextureEx(sprite, (Vector2){x, y}, 0, 2.0f, WHITE);
 61 }
 62 
 63 // This function waits for the guy to reach a specific position.
 64 void ConditionWaitForGuyPosition(int stepStart, int nextStep, int x, int y) {
 65     if (!IsStepActive(stepStart, -1)) {
 66         return;
 67     }
 68 
 69     guyTarget = (Vector2){x, y};
 70     if (Vector2Distance(guyPosition, guyTarget) < 1.0f) {
 71         scriptContext.nextStep = nextStep;
 72     }
 73 }
 74 
 75 // This function waits for a specified amount of seconds.
 76 void ConditionWaitForSeconds(int stepStart, float seconds) {
 77     if (!IsStepActive(stepStart, -1)) {
 78         return;
 79     }
 80 
 81     if (scriptContext.stepTimer >= seconds) {
 82         scriptContext.nextStep = scriptContext.currentStep + 1;
 83     }
 84 }
  1 #include "sample-3.h"
  2 
  3 // This file is our main file that runs the script and updates the game.
  4 // It handles drawing and moving our character and the background.
  5 // Everything else is handled by the script, which is defined in sample-3.c
  6 
  7 Texture2D guy = {0};
  8 Texture2D background = {0};
  9 Texture2D doorClosed = {0};
 10 Texture2D doorOpened = {0};
 11 Vector2 guyPosition = {0};
 12 Vector2 guyTarget = {0};
 13 ScriptContext scriptContext = {0};
 14 
 15 static void LoadAll() {
 16     guy = LoadTexture("data/guy.png");
 17     background = LoadTexture("data/background.png");
 18     doorClosed = LoadTexture("data/door_closed.png");
 19     doorOpened = LoadTexture("data/door_opened.png");
 20 }
 21 
 22 static void Initialize()
 23 {
 24     guyPosition = (Vector2){30, 10};
 25     guyTarget = guyPosition;
 26     scriptContext.currentStep = 0;
 27     scriptContext.nextStep = -1;
 28     scriptContext.stepTimer = 0.0f;
 29 }
 30 
 31 int main(void)
 32 {
 33     int screenWidth = 600, screenHeight = 240;
 34 #ifdef PLATFORM_WEB
 35     // needed to adapt to the dynamic size of the canvas element in browser
 36     double canvasWidth, canvasHeight;
 37     emscripten_get_element_css_size("#sample_2", &canvasWidth, &canvasHeight);
 38     screenWidth = (int)canvasWidth;
 39     screenHeight = (int)canvasHeight;
 40 #endif
 41     InitWindow(screenWidth, screenHeight, "Scripting Example");
 42     SetTargetFPS(30);
 43     
 44     LoadAll();
 45     Initialize();
 46 
 47     while (!WindowShouldClose())
 48     {
 49         BeginDrawing();
 50         ClearBackground((Color){ 82, 75, 36, 255 });
 51         for (int x = 0; x < GetScreenWidth(); x += background.width * 2)
 52         {
 53             for (int y = 0; y < GetScreenHeight(); y += background.height * 2)
 54             {
 55                 DrawTextureEx(background, (Vector2){x, y}, 0, 2.0f, WHITE);
 56             }
 57         }
 58 
 59         // Move the guy towards the target position and animate him with some hopping
 60         float hopping = 0.0f;
 61         float distance = Vector2Distance(guyPosition, guyTarget);
 62         if (distance > 0.25f) {
 63             Vector2 direction = Vector2Normalize(Vector2Subtract(guyTarget, guyPosition));
 64             float hopFrequency = 0.25f;
 65             hopping = fabsf(sinf(-distance * hopFrequency)) * 7.0f;
 66             // speed up when did a jump
 67             float speed = 15.0f + fabsf(cosf(distance * hopFrequency) * (hopping)) * 25.0f;
 68             float moveForward = fminf(speed * GetFrameTime(), distance);
 69             guyPosition = Vector2Add(guyPosition, Vector2Scale(direction, moveForward));
 70         }
 71 
 72         DrawTextureEx(guy, (Vector2){ guyPosition.x, guyPosition.y - hopping }, 0, 2.0f, WHITE);
 73 
 74         if (scriptContext.nextStep >= 0)
 75         {
 76             scriptContext.currentStep = scriptContext.nextStep;
 77             scriptContext.nextStep = -1;
 78             scriptContext.stepTimer = 0.0f;
 79         }
 80 
 81         ScriptUpdate();
 82 
 83         scriptContext.stepTimer += GetFrameTime();
 84         
 85         // Allow the user to reset the script so they can see it again
 86         const char *resetText = "Reset";
 87         int textWidth = MeasureText(resetText, 20);
 88         DrawRectangle(GetScreenWidth() - textWidth - 15, 0, textWidth + 10, 30, RED);
 89         DrawText(resetText, GetScreenWidth() - textWidth - 10, 5, 20, WHITE);
 90         if (CheckCollisionPointRec(GetMousePosition(), (Rectangle){GetScreenWidth() - textWidth - 10, 5, textWidth, 20}) &&
 91             IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) {
 92             Initialize();
 93         }
 94 
 95         // draw the current step number at the bottom left of the screen
 96         const char *stepText = TextFormat("Current step: %d", scriptContext.currentStep);
 97         textWidth = MeasureText(stepText, 20);
 98         DrawText(stepText, 10, GetScreenHeight() - 30, 20, WHITE);
 99 
100         EndDrawing();
101     }
102 
103     CloseWindow();
104 
105     return 0;
106 }
  1 #ifndef __SAMPLE_3_H__
  2 #define __SAMPLE_3_H__
  3 
  4 // This is our header file, which makes variables and functions accessible to other files.
  5 
  6 #include <raylib.h>
  7 #include <raymath.h>
  8 #include <math.h>
  9 
 10 // The ScriptContext manages the script relevant data; it could be extended to hold more data,
 11 // dependent on the requirements of the script.
 12 typedef struct ScriptContext {
 13     int currentStep;
 14     int nextStep;
 15     // The stepTimer resets every time the script advances to the next step.
 16     // It is useful for waiting for a specific amount of time or rendering animations.
 17     // It is in general a good idea to keep track of time in this script structure;
 18     // when every action and condition uses the script context as only source for 
 19     // time and data, we can easily pause the script or speed it up.
 20     float stepTimer;
 21 } ScriptContext;
 22 
 23 extern Texture2D guy;
 24 extern Texture2D background;
 25 extern Texture2D doorClosed;
 26 extern Texture2D doorOpened;
 27 extern Vector2 guyPosition;
 28 extern Vector2 guyTarget;
 29 extern ScriptContext scriptContext;
 30 
 31 void ConditionWaitForMouseClick(int stepStart, int jumpToStep);
 32 void ConditionWaitForGuyPosition(int stepStart, int nextStep, int x, int y);
 33 void ConditionWaitForSeconds(int stepStart, float seconds);
 34 
 35 void ActionDrawBubble(int stepStart, int stepEnd, const char* message, int x, int y);
 36 void ActionDrawSprite(int stepStart, int stepEnd, Texture2D sprite, int x, int y);
 37 
 38 void ScriptUpdate();
 39 
 40 
 41 #ifdef PLATFORM_WEB
 42 #include <emscripten.h>
 43 EMSCRIPTEN_RESULT emscripten_get_element_css_size(const char * target, double * width, double * height);
 44 #endif
 45 
 46 #endif

The sample-3.c file contains now only the script. It should be fairly straight forward to read and understand what is happening, even if it may not be fully clear, how this works.

To clarify how it does work, here's a quick rundown of the function:

Conclusions and comparison

Tastes can differ, but I believe the ScriptUpdate function in the last example is pretty clear on how the story is told. In a way, it doesn't look too different from a movie script:

        THE MAN
        Hello, I'm Forrest. I'm Forrest Gump.

    She nods, not much interested. He takes an old candy kiss 
    out of his pocket. Offering it to her:

        FORREST (cont'd)
        Do you want a chocolate?

Of course, we add a number of instructions for our systems, but in principle, it resembles it quite closely, in my opinion.

As initially mentioned, this is the immediate mode variant. The retained mode variant looks however pretty similar in effect, at least the setup of the script is. Retained means, that we have a ScriptInit function which is called only once to set up the data that is later used to execute the script - instead of being called every frame.

For a huge script, this could be useful to optimize the performance, since executing a lot of function calls that are doing nothing is not very efficient - but don't be quick to judge, a retained mode system doesn't come cost free since the complexity of managing the memory and selecting the right code to run comes at a cost. The simplicity of the immediate mode variant is hard to beat.

A more important difference is, that it can be more difficult to handle execution order differences in the immediate mode case. For example, if we want to draw a sprite on top of another sprite, we have to make sure that the execution sequence of the actions is following that logic. That can mean that the order of the actions and the order of the script steps is not aligned! This can be a source of bugs that are hard to find. For example, I was struggling once to figure out why a transition was not drawn, just to figure out that a follow up step was clearing the screen. It is possible to handle this, but it can be less straight forward than in a retained mode system.

Another problem can be when the game has an "update" and a "draw" phase and various actions or conditions need to be executed in the correct phase. This can be handled by adding a phase to the script context and checking the phase in the actions and conditions and executing the ScriptUpdate function twice, once for the update phase and once for the draw phase.

Another downside of the immediate mode is, that most engines use a retained mode for preparing the drawing operations (e.g. GameObjects with MeshRenderers in Unity) and synchronizing both modes can be difficult.

As for smal raylib or Löve2d based games, the immediate mode variant is usually sufficient and easy to implement.

One clear advantage of the retained mode is, that it allows doing more complex calculations as part of preparing the script. This could be important in case the script is procedurally generated based on some conditions, for example creating walking paths for NPCs based on procedurally generated terrain.

🍪