What if We Just Made Our Own Scripting Language
This is Part 3 in a series, and references a lot of Part 2
One of the fun things about modern game dev is that oftentimes you have to make something really complicated to make the actual approach easier.
Last time, I said that stats are the second-most complicated. So what's the most?
Skills
Skills are, by far, the most difficult thing to implement in a JRPG. It sounds pretty simple, right? You just pick how much damage it causes, what status effects it does, maybe some stat changes...
And yeah. That part's not bad. But then you want conditionals. You want your skill to only activate sometimes. Maybe it'll change based on that conditional, like Castform's Weather Ball. Maybe weather will make it stronger or weaker, maybe it'll only work on the opposite sex. What about the difference between a passive, an ability, and an item? Will they all use that same conditional block, or will you have to write out a different one? What are you gonna do when you want to add or change the way these conditionals work?
You could make a different class for each skill, but your compiler is gonna chug. Reusing the code (and subsequently updating it for every class) is also going to be a chore.
You could also have a giant struct filled with bools, or even a bitmask. Then you'd have to deal with a big ol' switch statement that might lead to other switch statements, and all of a sudden your code is a fine italian cuisine.
Orr, you could integrate a DSL, and turn that cuisine into an Olive Garden.
The Domain-Specific Language (Not the other kind of DSL)
Every language is built on top of another, until you go all the way down to pure binary. What makes a DSL a little different from these languages is that DSLs aren't usually compiled, instead being used more as fancy function callers.
As it doesn't need to be compiled, we can write and modify the script directly in the editor, or use as a language for modders to take advantage of when expanding your game (without needing to inject code themselves).

To Further Explain
Shader languages like GLSL and HLSL, and markup langauges like HTML are all DSLs.
GLSL/HLSL are reinterpreted into instructions for the Graphics API, and HTML are broken out into elements and their components. But all the functions that do these things are integrated in a different, underlying language.
The general idea is that we have a method that reads the script, then it calls existing "wrapper" functions. It vastly simplifies interactions that would be a hassle to juggle with structs, and creates a modular, resuable solution across your project.
While this can be a rather complicated affair if you want the real "traditional" coding structure, DSLs tend to be a lot simpler. For Origin, I'm going to be using my own Fen language.
Fen
If you noticed, that screenshot up there showed an example of Fen. Here it is in text form:
if not target type fruit
set target type fruit
target damage 30
else
target damage 100The idea is pretty easy to understand at the end of the day, so let's break down one by one. First of all, every single thing in the script is either a function, reference, or a value for the function.

These functions always return a struct known as a Package. The Fen Package is a container for the context each function is given and can modify, and is the lifeblood of this DSL.
public struct Package
{
public List<string> instructions;
public string op;
public bool cont, not;
public Fen.Context ctx;
public IFen scope;
public string func => instructions[0];
public string Value()
{
Next();
return instructions[0];
}
public Package Next()
{
instructions.RemoveAt(0);
return this;
}
}We then have a delegate named Function.
public delegate Package Function(Package pkg);
Delegate?
Delegates are extremely powerful variables that allow you to store method calls within them, arguments included. The most common Delegates are called Actions, but since our Function delegate is meant to return a Package, we have to use this custom one.
Example:
Action del = () => Foo();Action<int> delbar = bar => Foo(bar);
We then call the method we stored with
del(); delbar(1);
And an Interface named IFen
public interface IFen
{
public class FenFunctions : Dictionary<string, Fen.Function> { }
public FenFunctions Functions();
}A What Now?
An interface is kinda like a class template of sorts. While it's (generally) not allowed to do anything beyond telling a class what functions it should have, you can cast a variable to it, allowing you to access their functions in a generic way.
You're also allowed to have a class inherit as many interfaces as you want, so go nuts. Just remember that a class MUST have every single function each interface wants.
Example:
public class Foo : MonoBehaviour, IFen
Foo foo = new();
var bar = foo as IFen;
Inheriting from a typed dictionary?
Yep! If you add a type to a generic, you can indeed have a new class inherit that. It's a great shorthand and keeps your code looking tidier if you're gonna reuse it a lot.
Now that that's out of the way, it's time to tackle our interpreter.
Fen Interpreter
public class Fen : IFen
{
public class Context : Dictionary<string, IFen> { }
public FenFunctions Functions() =>
new()
{
{"if", pkg => If(pkg) },
{"not", pkg => Not(pkg) },
{"set", pkg => Set(pkg) },
{"else", pkg => Else(pkg) },
};
//Fen Functions
public Package If(Package pkg)
{
pkg.op = "if";
return pkg;
}
public Package Not(Package pkg)
{
pkg.not = !pkg.not;
return pkg;
}
public Package Set(Package pkg)
{
pkg.op = "set";
return pkg;
}
public Package Else(Package pkg)
{
pkg.cont = !pkg.cont;
return pkg;
}
}Here's the basic Functions system. As you can see, it reads the name of our function and then directs it to an existing method within the currently "scoped" IFen class.
Explanation
- "op" is the current operator.
- This allows us to use a switch statement within a function, as I'll show later
- "cont" tells Fen if it should read the next line or not
- "not" flips the cont.
The constructor method itself takes a Fen script as input, as well as the context we build when using this.
public Fen(string script, Context ctx)
{
Package pkg = new() { cont = true, scope = this, ctx = ctx, not = false };
var lines = script.Split('\n');
foreach (var line in lines)
{
pkg.scope = this;
pkg.instructions = line.Split(" ").ToList();
if (!pkg.cont && !ignoresCont(pkg)) { Debug.Log($"Ignoring {line}"); continue; }
while (pkg.instructions.Count > 0)
{
pkg = Next(pkg);
}
if (pkg.not) { pkg.cont = !pkg.cont; pkg.not = false; }
}
}Explanation
- We're Initializing the Package, making sure to set the scope to the Fen class and the target to whatever we define as that
- breaking the script down by newline
- doing a foreach on all lines
- Setting the "scope" back to the interpreter each line read
- further splitting the line by spaces
- Checking if pkg.cont is false, or if the first instruction is "if" or "else"
- doing a while loop until the package runs out of instructions
- Calling the Next method
- Flipping the resulting cont if pkg.not is true
- Resetting not
The Next Method
public Package Next(Package pkg)
{
Debug.Log($"{pkg.scope} -> {pkg.func}");
if (pkg.ctx.ContainsKey(pkg.func)) return Scope(pkg);
if (pkg.scope.Functions().ContainsKey(pkg.func))
pkg = pkg.scope.Functions()[pkg.func](pkg);
else
pkg = Functions()[pkg.func](pkg);
return pkg.Next();
}For Next() we first check if the function is referencing the package's context. If it does, we move to the Scope function.
public Package Scope(Package pkg)
{
pkg.scope = pkg.ctx[pkg.func];
return pkg.Next();
}Scope lets us switch a package's "scope", which is the current object from which is the current IFen to read its functions from
Further Explanation
- If it's not part of the context, we then check the IFen's Functions to see if there's a matching one, and to invoke it if so.
- Otherwise, we check to see if that function instead exists in the interpreter.
- After that, we call pkg.Next(), which removes the first element from the instruction list.
A future addition to implement would be to check if the Fen has that function as well, and if not to call an Error function and break the script parsing entirely.
And that's pretty much it. It's now ready for implementation.
Testing it out
The next thing to do is to go back to our BattleTest script we made and add a quick test after having the BattleManager do its StartBattle.
//Multiline is an attribute that lets you use newline in the inspector. The "100" is how many lines can be displayed at once.
[Multiline(100)]
public string FenTest;
void Start()
{
...
var context = new Fen.Context()
{{"target", BattleManager.instance.opponent },};
var test = new Fen(FenTest, context);
}"opponent" being public Battler opponent => cards[0].battler; for now. Once we get the battle system functional, it'll be based on a Battler's target.
Now we go over to Battler, which has changed to this:
public class Battler : MonoBehaviour, IFen
{
public IndividualMonster monster;
public new SpriteRenderer renderer;
public Dictionary<string, Fen.Function> Functions() =>
new()
{
{"type", pkg => Type(pkg)},
{"damage", pkg => Damage(pkg)},
};
public void Set(IndividualMonster newMonster)
{
monster = newMonster;
renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.TwoSided;
renderer.sprite = newMonster.data.battleSprite;
}
//Fen Functions
Package Type(Package pkg)
{
switch (pkg.op)
{
case "if":
Debug.Log($"{monster.name} is not a {pkg.Value()} type.");
pkg.cont = false; //types aren't implemented yet :<
break;
case "set":
Debug.Log($"{monster.name} is now {pkg.Value()} type.");
//We'd set a temporary type change here.
break;
}
return pkg;
}
Package Damage(Package pkg)
{
var value = int.Parse(pkg.Value());
Debug.Log($"{monster.name} was dealt {value} damage!");
monster.hp -= value; //Insert damage calculation here
return pkg;
}
}
Explanation
- Battler now has the "type" and "damage" Fen functions
- by using a switch for the Package's stored operator, we can split the function based on what it wants to do
- pkg.Value() is similar to pkg.Next()--it also moves the instructor forward--but it returns the removed instruction as a string.
- The beauty of Fen is how modular it is, so you can extend or change functionality as needed.
- I may wind up changing this to be more robust in the future.

And there we have it. A fully-implemented DSL. Next time, we're gonna implement skills.
Read about it here.
Member discussion