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:
- Easy to write
- Easy to understand
- Quick to test
- Easy to iterate
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:
- Tutorials: When a tutorial is a railroaded sequence of events, a script is a perfect match.
- Cutscenes: Cutscenes are usually a sequence of events that are not controlled by the player. A script can be used to define these sequences.
- Dialogues: Dialogues are usually a sequence of events that are controlled by the player. A script can be used to define blocks of dialogues that are triggered by the player.
- Events: Events are usually a sequence of events that are triggered by the player and similar to cutscenes
- Quests: Quests are usually a sequence of events that are triggered by the player as well and require conditions to proceed in the script.
- NPC action sequences: NPCs may become part of the script; walking to a point, triggering a dialogue, pushing switches, etc. A script can be part of their AI behavior as well.
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:
- Step: A step is a simple integer number that is typically only increased during script execution.
- Action: An action is a function that is associated with a range of steps and executed when the current step is active. It can be a dialogue line, a movement, a camera change, an image drawn on screen, etc.
- Condition: A condition is a check that is used to proceed to a different step in the script. It can be a check if a button is pressed, a check if a certain time has passed, a check if a certain variable is set, etc.
Conditions and actions can combined into one single action, e.g. an action that waits for a button press to proceed while drawing a dialogue line. There are however situations in which it needs to be separated.
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:
- Everything is defined in one function.
- Using hardcoded numbers makes it pretty difficult to insert new steps or to change the order of steps.
- It's not easy to see at a glance what the script does as it contains logic and control flow instructions.
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:
- The ScriptUpdate function is called every frame
- The called functions, e.g. ActionDrawBubble are called regardless of the step
- In each of these functions the scriptStep is checked. If the action or condition is not supposed to run at the current step, the function simply returns and does nothing.
- The ScriptContext is currently a global variable and is accessed by the functions to get the current step and to set the next step. A cleaner approach is to pass the context to each call. This way we can have multiple scripts running at the same time, e.g. the behavior of a NPC and the dialogue sequence of the story or a tutorial.
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.