
Zephyr: A State-Based, Event-Driven, Domain-Specific Language for 2D, Top-Down Action RPGs
About
My thesis project, Zephyr, is a domain-specific language ( scripting language designed for a specific use ) that defines state machines that can listen to and fire events to interact with a custom C++ game engine. Source scripts are compiled into bytecode when the game loads on initial startup or via a hotkey. The bytecode is interpreted by a virtual machine (VM) at runtime whenever a script event is fired. The language is demonstrated with a 2D action role-playing game (RPG) with enemies to fight, keys to collect to unlock doors, and quests to complete, similar to games such as The Legend of Zelda (NES) and Dungeon Explorer.
Key Features
Fast Iteration
The main objective for this project was to build a system that would let programmers and designers script gameplay features quickly. The scripts are compiled at the start of the game and there is a hotkey to reload the game so that scripters can quickly iterate on behavior without needing to stop the application and recompile C++ code. The language is designed to be simple while providing built in support for useful game mechanisms, such as Entity and Vec2 native types, state machines, and event handling to interact with the game engine.
the

Compiler and Virtual Machine
Zephyr scripts are written in .zephyr files which are read in as text by the game engine. The text is then compiled into bytecode, a vector of bytes containing op codes and indices into a separate constant vector that is stored for each bytecode chunk. At runtime, each bytecode chunk is interpreted by a virtual machine (VM) which processes the bytecode vector one byte at a time and performs the requested operations on the data loaded in from the constant vector. There are 2 main ways that bytecode is ran in my system, on an entity update and as a response to an event.
Entity Update
Each entity in the game has an update function that will check if there is a script associated for the entity and call it's update method if so. Each script is represented in C++ as an object that keeps track of all the bytecode chunks associated with the script. On update, if the script's current state defines an update function, the corresponding bytecode will be executed.
Event Handlers
Scripts can define Functions, which are saved as separate chunks of bytecode as a map in their parent state. The function bytecode is interpreted when the event system fires the corresponding function, either from the engine or as a result of another script's code.
Error Handling and Recovery
When a compile or runtime error is thrown, the resulting error is printed to the dev console. Any entity that has a script that throws an error is put into an error state. When an entity is in error, the text "Script Error" is printed on it in the world and no script code will be ran for that entity, update or event handlers. Errors are recoverable and can be fixed in the code editor and hot reloaded to apply the fix.

Script Features
States
Each script can define a set of states and use the ChangeState function to switch between them. States have 3 built in functions, OnEnter, OnUpdate and OnExit that are called when transitioning between states and in the case of OnUpdate, when the parent entity updates.
Functions
Scripts can define Functions, which use the game engine's event system to fire. A script can call functions defined in other scripts or a set of special events that are defined in the game engine to be exposed to scripts. These events are documented with Doxygen so designers can reference what functionality is available.
Variables passed into a Function are done so by reference which allows the variable to be updated and serves as a way to return data from a Function call.

Entity Variables
References to other entities can be defined as Entity variables in a script. The variable can then be used to access any global variables or functions defined for that entity. If a non-existent member is accessed, an error will be thrown at runtime.
This feature was implemented based on feedback from level designers. One of the major pain points in existing scripting languages they had used was communicating between entities intuitively.
The original implementation utilized an event broadcast approach for function calls and relied on interested parties to filter the events themselves. For instance, if an event was fired to open a door the door would check the event data to see if the name of the door to open matched its own name and if so, open. This approach wasn't very intuitive and led to performance problems since all function calls were being broadcast to all entities.


By using an Entity variable, the door example could be reworked so that the entity trying to open the door could have a reference to the door and just call door.Open() when necessary. The designers ( and myself ) found the Entity strategy much more intuitive.
Post Mortem
What Went Well
-
I was able to set milestone goals at the beginning of the project and consistently hit those goals. I had a good sense for my velocity and didn't wildly underscope or overscope the project.
-
I adapted to feedback and new discoveries well. Although I began with a particular vision I was able to iterate and update my plans as the project progressed to ensure the final product would be the best it could be.
-
Designers have been able to use my language to modify the test game and make cool stuff!
-
I used the language in another project, Diablo's Gate, a Diablo inspired action RPG and was able to quickly add game features. With a few modifications to the engine side event calls, I was able to handle mouse click events and all entity and combat logic in the scripts.
What Went Wrong
-
My initial approach resulted in a syntax design that followed too closely how the backend implemented the language, resulting in a clunky user experience. Later, once I approached the syntax design as a designer would and had the parser do the tricky work of converting that into my existing bytecode, the language was much more intuitive.
-
Some features like member accessors, pass-by-reference variables, and Entity variables came about from feedback I received later on in the project. It was challenging to add these later while ensuring the existing design still worked. I think getting feedback earlier in the process would have allowed me to integrate those features earlier on and may have been less painful.
What I Learned
-
Get feedback from the people who will be using your tools early and often. A ton of great features were added due to how others expected or wanted to use the language and I definitely would not have come up with all of those on my own.
-
Pay attention to the little things people instinctively do while using a system. If everyone is making the same mistakes that may be a sign that your design needs to be updated to accommodate those instincts.
-
While I had worked on compiler projects in the past, this was the first time I'd ever designed my own syntax and integrated with a game engine. I underestimated how much time and thought needs to go into syntax to make it intuitive and easy to use. Likewise, there is a lot of work that needs to be done on the game architecture side to support useful features that the scripts can use.