game design, code, writing, art and music

English По-русски

Designing a custom scripting language for my game engine

Perhaps the biggest advantage of rolling a custom game engine is being able to fully control the workflow. I can change 3D models, textures, entities, sounds and other data while my game is running - then press a key and reload it without recompiling. This is convenient, fast, and rids me of any interruptions. I like this.

Naturally, I want the same flexibility to apply to writing the actual game logic. This includes things like level progression, interactions with NPCs and other entities, dialog trees and so on. For this purpose I am creating a scenario system and a custom scripting language called YumeScript.

The system is already mostly implemented and in working condition. I'd like to share my experience in this post.

The Requirements

The game I'm working on is an action adventure RPG, where the world consists of multiple inter-connected levels - zones. Each zone consists of a map - the actual chunk of the game world (which includes the terrain, enemies, NPCs, interactable entities and so on), and a script - the game logic that defines the behavior of the entities on this map. Whenever a zone is loaded, the game initializes the map and runs the associated script.

Before coming up with the syntax of the new language, I needed to clearly define my requirements of it.

First of all, the language should be very high level. I don't want to deal with class instances, arrays, matrices or anything else that's not directly related to the gameplay. If I want to put a dialog with an NPC here - there needs to be a single command that starts a conversation. If I want to give the player an item at the end of the conversation - there needs to be a single command that adds an item to the inventory and displays a box that says "Obtained X!".

For this reason I don't need to implement a fully featured language like Lua or hscript. What I have in mind is closer to SCUMM and Warcraft III World Editor triggers. In fact, I could probably get away with storing all this game data in JSON files. That's what I did in Hypnorain and Speebot.

The second requirement is that the language has to be readable and compact. This is why I decided not to use JSON files - too much visual bloat, especially when it comes to tree structures. Simple syntax allows me to focus on the important things and reduces chances of bugs.

Finally, because of the nature of the game, the language should be based around event-driven scripting. Games are interactive: most things happen as a result of some specific events. It should be easy to define that kind of interactivity when writing a script.

The Script

After I settled on the requirements, I sketched up a mock script.

Behold, the Potion Shop scenario:

Event [enter_zone]:
	Play music [fantasy1.ogg]
	Show area name [Potion Shop]
	
Interaction with entity [npc_potion_seller] [Potion seller] hitbox [hit-box]:
	Actor [Potion seller] [friendly] [2] says [ps1 # Welcome to my potion shop, traveler. What would you like?]
	Label [Choice point]
	Option [ps2 # I'm going into battle. Give me your strongest potion.]:
		Actor [Potion seller] [grin] [2] says [ps3 # You don't know what you ask, traveler. My strongest potions will kill a dragon, let alone a man!]
		Actor [Potion seller] [friendly] [2] says [ps4 # Anything else?]
		Go to [Choice point]
	Option [ps5 # I'd like a weaker potion.]:
		Actor [Potion seller] [grin] [2] says [ps6 # That will be 10 gold coins.]
		Option [Sure. (10 coins)] if [money] >= [10]:
			Actor [Potion seller] [grin] [2] says [ps7 # A wise choice!]
			Modify [money] subtract [10]
			Obtain [weak_potion] [1]
		Option [ps8 # I don't have that much money!] if [money] < [10]:
			Actor [Potion seller] [angry] [2] says [ps9 # Stop wasting my time, traveler.]
		Option [ps10 # Maybe later.]:
			Actor [Potion seller] [disappointed] [2] says [ps11 # Very well.]
	Option [ps12 # Nothing right now.]:
		Actor [Potion seller] [friendly] [2] says [ps13 # Well, come back when you change your mind.]

Interaction with block [exit_doorway] [To market district] if ([door_unlocked] is true):
	Zone [Market District]

As you can see, the idea is simple - each line is a command, which consists of keywords and values surrounded in square brackets. Keywords are used to provide the context and the type of the command. The values, depending on the context, may mean different things - variables, numbers, actor IDs, translatable strings and so on.

Some commands can contain children. Nesting is achieved with tab indentation. Indentation with spaces is considered heresy and therefore ignored.

The highest level of the script file consists of event listeners. I can listen to global events, such as "enter_zone", as well as specific interactions between entities. Each listener can optionally include a conditional "if" statement, and will only be triggered if the conditions are met.

Variables, such as "money" in the example, can be of 3 types - booleans, integers and strings, and are internally stored in 3 hashmaps by the game's scenario manager. The player's inventory is stored in a similar way. These objects are shared between all scripts, and will be included in the save files.

Some text strings, such as dialog lines, can be translated. Each translatable value is prefixed with a unique ID. The ID is followed by the text in the default language (English). If I need to add translations later - those will be stored in a separate file and will use the IDs I've already defined.

Parsing and Compilation

Now I'll share some details about my implementation. I've never actually researched or designed language compilers or interpreters before, so some of my definitions may not be accurate. Works for me, though!

The script file is loaded, parsed and compiled whenever its' parent zone is loaded. While in debug mode, I can make changes to the script and recompile it immediately. All compilation errors are immediately displayed with a descriptive error message and the location of the error in the file.

The compilation consists of two stages:

  • The text file is parsed. Unnecessary whitespaces, commented lines and similar trash is detected and removed. The parser produces a hierarchy of YsComponent objects. A component contains two arrays: tokens and children. Tokens are just an array of strings that I get by splitting the line by whitespace, except that each value (surrounded with square brackets) is considered a single token. Children are commands in the next indented block. At this stage, the compiler may throw errors if the indentation is wrong, or if the square brackets are somehow messed up.
  • The component tree is turned into a tree of commands. Each command type is an instance of a specific class, for example, YsComGoTo. Each command has its own logic that can be executed by the engine when required. If the compiler does not recognize the combination of tokens - an error is thrown. An error is also thrown if a value is of a different type (for example, a number is expected instead of a string). This stage handles syntax validation of each command, and in the end I end up with a tree of specific command objects, which can be executed when required.

If the compilation is successful - I end up with a tree of ready-to-use command objects. Event listeners are then hooked to the internal event system and are ready to be executed by the game when the time comes.

The Walking State Machine

Now that the compiler has constructed an object tree, I need a way to actually run it and execute all that logic. This is done by something I call "The Walking State Machine". Actually, I just call it a "Runner".

Whenever an interesting event is caught, and if that event is described in the script file - I spawn a new Runner object, which goes through all the commands in the block in sequence. The interesting part is that each command decides on its own when to advance the Runner to the next step. This way, a command called "Play music" will begin playback of a song and advance the runner to the next command immediately. Afterwards, a command called "Dialog info" will pop up a dialog box for the player, and the runner will not advance until the player has read the message and clicked "OK".

Here's an example of two dialog boxes popping up in a sequence:

Event [enter_zone]:
	Label [beginning]
	Dialog info [title1 # First!] [msg1 # Informational dialog boxes can be used for tutorials.]
	Sleep [20]
	Dialog info [title2 # Second!] [msg2 # Insert insightful game tip here.]
	Sleep [60]
	Go to [beginning]

In essence, each Runner is a state machine that "walks" step-by-step through a sequence of commands at the pace defined by those commands. This is how command blocks are run in YumeScript.

Another interesting thing is that different event listeners can each have their own runners active at the same time. In other words, concurrency is supported.

You may have noticed a "Go to" command in the example above. While usually not a recommended practice in most modern programming languages, this feature is very useful in game scripting - especially in conversation branches that return the player to previous choice selection states.

YumeScript for Data Storage

Since I've already got a compiler working, I've decided to use the same format for storing some common game data that is not tied to any specific zone. For example, I can store actor descriptions in a separate YumeScript file like this:

Actor type [Potion seller]:
	Name [actor_potion_seller # Potion seller]
	Emotion [grin] portrait [potion_seller_grin.png]
	Emotion [friendly] portrait [potion_seller_friendly.png]
	Emotion [disappointed] portrait [potion_seller_disappointed.png]
	Emotion [angry] portrait [potion_seller_angry.png]

It's useful, because I've already implemented things like translatable strings in the compiler, and these features carry over to the data files nicely.

Conclusion

Overall, I'm really happy with how the whole thing turned out. The language is another tool to aid me in game development, and it's already proving its' usefulness. It was pretty quick and intuitive to implement it, too. Now every time I implement a new game feature, I can add a command for it, and then start experimenting with it right away.

That's the gist of it!

Next Article

Working on a new puzzle exploration game

Subscribe

Receive a notification on your device whenever there's a new blog post available, in order to:

Follow the development process of my next game.

Get notified of articles about the art and tech of designing games and engines.

Receive updates about changes to my games.

Subscription is free and takes just one click!