I am currently working on my small game engine and when I presented to two of my colleagues one of them immediately said something along the lines of:

You definitely need some kind of visual scripting like Unreal Blueprints.

And at first, I was like “no I don’t want that, I already put so much work into the integration of Lua and I don’t like Unreal blueprints anyway”. However, deep down I knew that he had a point. Visual scripting makes rapid prototyping so much easier, especially if you do not have any experience working with that particular engine. At work, we mainly use the Unreal Engine and I have to admit that with Blueprints you can get something up and running extremely quickly. The thing I do not like about such graph-like visual programming languages is that they get convoluted pretty quickly and you have to put a lot of work in to make them look nice. In my opinion even more so than with text-based programming languages. There is even a Tumblr page called UE4 Blueprints From Hell that shows how ridiculous it can get. To be fair, it’s always possible to write/design ugly code but I think the free positioning of programming elements on a 2D canvas is not helping here.

So after that discussion, I created an issue called Add node-based scripting and forgot about it immediately. Quite some time later I played around with the Shortcuts feature on my iPhone which also includes a very high-level visual scripting to perform some actions.

I immediately liked the concept and I was eager to integrate something similar to this into my engine. At first, I wanted some kind of visual frontend that would be converted to Lua code but I wanted a lot of additional features like a static type system. This required that I needed to register all my types and functions to another place and I finally ended up writing my own virtual machine to run the scripts.

The GUI will generate a JSON file that contains all logic of the script. The resulting JSON for the first Hello World example would look something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
  "type": "function",
  "name": "foo",
  "inputs": [],
  "outputs": [],
  "actions": [
    {
      "type": "function_call",
      "function": "Print text",
      "module": "Core",
      "inputs": {
        "text": "Hello World"
      }
    }
  ]
}

Here a new function called foo is defined that has neither inputs nor outputs. Each function contains a list of actions (usually called statements in more classical programming languages) that are executed one after another. Here, only a single action is executed: a function call to the function Print text from the module Core.

Currently, the action types function_call, if, while and return are supported which should all be very self-explanatory. An example for an action that defines a while loop looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "type": "while",
  "codition": true,
  "actions": [
    {
      "type": "function_call",
      "function": "Print text",
      "module": "Core",
      "inputs": {
        "text": "Are we there yet?"
      }
    }
  ]
}

This snipped would define a loop that endlessly prints the text “Are we there yet?”. Variables are implicitly created by specifying a name for an output, like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "type": "function_call",
  "function": "Create Vector2",
  "module": "Core",
  "inputs": {
    "x": 100,
    "y": 150
  },
  "outputs": {
    "vector": "An awesome vector"
  }
}

This action will create a local variable with the name An awesome vector. Note that variable names, as well as function names, can contain spaces and other special characters. The variable can then be used as an input in the following actions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "type": "function_call",
  "function": "Length of Vector2",
  "module": "Core",
  "inputs": {
    "vector": {
      "inputType": "local_variable",
      "name": "An awesome Vector"
    }
  },
  "outputs": {
    "length": "Length of awesomeness"
  }
}

Yes, the resulting JSON will become very complex very quickly. However, you should never edit it by hand but through the visual scripting GUI.

On the C++ side of things here is an example of how types and functions are registered to the virtual machine.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
struct Vector3 {
  float x;
  float y;
  float z;

  float length() const {
    return std::sqrt(x*x + y*y + z*z);
  }
};

Vector3 Cross(Vector3 lhs, Vector3 rhs) {
  return {
    .x = lhs.y * rhs.z - rhs.y * lhs.z,
    .y = lhs.z * rhs.x - rhs.z * lhs.x,
    .z = lhs.x * rhs.y - rhs.x * lhs.y,
  };
}

std::tuple<bool, int, std::string> MultipleOutputs() {
  return std::make_tuple(false, 42, "Hello World");
}

void RegisterVector3(Module* module) {
  // Register vector type
  vm::Type* vector3_type = module->RegisterType<Vector3>("Vector3");

  // Register properties, either by a pointer to a member...
  vector3_type->RegisterProperty<&Vector3::x>("x");
  vector3_type->RegisterProperty<&Vector3::y>("y");
  vector3_type->RegisterProperty<&Vector3::z>("z");

  // ... or via getter and an optional setter.
  vector3_type->RegisterProperty<&Vector3::length, nullptr>("length");
  
  // Registering functions is also quite easy: 
  module->RegisterFunction<&Cross>( // Function pointer as template parameter
    "Cross",                        // Name of the function
    { "lhs", "rhs" },               // Input names (types are automatically deduced).
    { "result" }                    // Output names (types are automatically deduced).
  );
  
  // Returning a tuple is the C++ way of having multiple outputs 
  module->RegisterFunction<&MultipleOutputs>(
    "Multiple Outputs",
    {},
    { "Am I cool?", "Meaning", "Greeting" }
  );
}

void foo() {
  vm::Value vector = Vector3 {
    .x = 1,
    .y = 2,
    .z = 3,
  };
  vector.SetAttribute("x", 10.0);
  // The above could be simplified to vector["x"] = 10.0, however, it
  // is mostly never used directly so it is probably not worth the hassle.
  float length = vector.GetProperty("length");

  vm::Function* cross = vm::Module::Get("Core")->GetFunction("Cross");

  // Call() calls the function. Parameters can be either a vm::Value or
  // The actual C++ type. Everything is converted automatically. The
  // templace parameter specifies the return value(s).
  Vector3 result = cross->Call<Vector3>(vector, Vector3{ .x = 2, .y = 3, .z = 4 });

  vm::Function* multiple_outputs = vm::Module::Get("Core")->GetFunction("Multiple Outputs");
  std::tuple<bool, int, std::string> results =
    multiple_outputs->Call<bool, int, std::string>();
}

That’s it for a first overview of my visual scripting adventure. I am working on it for quite some time now but there is still a lot to do. Next, I want to work on generic types like lists or maps. And there is also the topic of performance which I did not pay much attention to at all until now. So, stay tuned! This will not be the last post about scripting.