Project

ScriptKit - In-game interpreter plugin

A custom programming language & interpreter for programming games in Unreal Engine.

ScriptKit - In-game interpreter plugin
ScriptKit - In-game interpreter plugin

Overview

ScriptKit is a C++ Unreal Engine plugin that enables developers to create coding-based gameplay experiences through a custom Lox-inspired language.

Games like The Farmer was Replaced & RoboMind show that there is an increased interest in games where programming is the main mechanic, whether for educational purposes or just as gameplay. For this personal project I set out to create a plugin that would make it easy for developers to create programming gameplay in Unreal Engine.

My contributions

Custom programming language: Flex

No programming game is the same. You might want for loops and custom function calls, or none at all - you might want it to be in English, or you might want it to be in your native language.

To allow the developer full customization of the language, and to gain a better understanding of programming languages & compilers, I decided to make my own custom programming language from scratch. Following the Crafting Interpreters book by Robert Nystrom, I implemented a custom bytecode compiler & interpreter with type-erased variables (using std::variant & std::visit), operators, for & while loops and if-else statements as well as various standard library functions.

Video showcasing Flexlang running on the terminal with variables & statements

Unreal Engine integration

For ease of development I implemented my interpreter as a separate library, which I included as a submodule in my Unreal plugin repo.

To make the interpreter accessible & customizable to the end-user, I created a subsytem which holds the interpreter state. This exposes simple Blueprint nodes usable in Widget Blueprint UI.

For more custom behavior, I also added user-defined events that can e.g. trigger a restart of the level when the interpreter finishes executing. I also added logging interfaces that the user can attach any custom UI to.

Finally, I added UDeveloperSettings that expose things like execution speed and standard library function configurations for further control over the interpreter.

Logging interface connected to WBP UI

Events Blueprint C++ delegates exposed to Blueprint - used to reset level

Technical implementation (read more)

To prevent the game from hanging during script execution (imagine being stuck in an infinite loop inside of the game), I split up the execution into single bytecode steps.

Using an accumulator, we can even have the user customize the execution speed (e.g. a temporary ‘fast-forward’ button).

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
void UFlexInterpreterSubsystem::Tick(float DeltaTime)
{
	if (!bIsInterpreterRunning || bIsInterpreterPaused) return;

	AccumulatedTime += DeltaTime;
	while (AccumulatedTime >= TimePerOpcode)
	{
		AccumulatedTime -= TimePerOpcode;

		// Execute a single instruction
		flex::InterpretResult interpretResult = vm.Step();
		if (interpretResult != flex::InterpretResult::INTERPRET_STEP_OK)
		{
			if (interpretResult != flex::InterpretResult::INTERPRET_OK)
			{
				// Runtime error
				GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Red, TEXT("Error interpreting script!"));
				OnRuntimeErrorDelegate.Broadcast();
			}
			else {
				// Reached end of script
				GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Green, TEXT("OK!"));
			}
			StopScript();
			break;
		}
	}
}

Blueprint function binding

The programmed behavior will also differ per-game. You might script a robot navigating through a maze, or a turret shooting at hordes of enemies.

To easily allow developers to add custom behavior in the language, I added support for extending the standard library with Blueprint function bindings.

All the developer has to do, is add the FunctionBindingComponent and write the name of the Blueprint function. Using UE reflection magic, the component then automatically retrieves the UFunction, analyzes the parameter types and registers it to the interpreter, including proper error logging for incorrect parameter types.

Video showcasing Blueprint function binding to interpreter

Technical implementation (read more)

To call Blueprint functions from our script, we first need to find the user-created function, and then execute it on the specified actor instance. If the function has arguments, we also need to make sure that these match our function signature.

Using Object->ProcessEvent(Function, Params); we can call the Blueprint function by reference. We pass in a buffer which will contain our input parameters as well as a possible return value.

To handle the memory around the Params buffer safely, I created a simple RAII struct:

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
struct FParamsBuffer
{
	void* Buffer;
	UFunction* Function;

	FParamsBuffer(UFunction* InFunction)
		: Function(InFunction)
	{
		Buffer = FMemory::Malloc(Function->ParmsSize);
		FMemory::Memzero(Buffer, Function->ParmsSize);

		// Initialize non-trivial properties
		for (TFieldIterator<FProperty> It(Function); It && It->HasAnyPropertyFlags(CPF_Parm); ++It)
		{
			It->InitializeValue_InContainer(Buffer);
		}
	}

	~FParamsBuffer()
	{
		if (!Buffer) return;
		for (TFieldIterator<FProperty> It(Function); It && It->HasAnyPropertyFlags(CPF_Parm); ++It)
		{
			It->DestroyValue_InContainer(Buffer);
		}
		FMemory::Free(Buffer);
		Buffer = nullptr;
	}

	void* Get() const { return Buffer; }
};

Using a TFieldIterator<FProperty> we can then analyze and write into the buffer, and finally call the function with the method I described above. If there is a return value we extract it from the buffer and return it back to the user in the script.

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
flex::NativeReturn CallFunctionOnObject(TWeakObjectPtr<UObject> Object, TWeakObjectPtr<UFunction> FunctionToCall, std::span<flex::Value> Args)
{
	if (!Object.IsValid() || !FunctionToCall.IsValid())
	{
		UE_LOG(LogTemp, Warning, TEXT("Object or function is invalid"));
		return flex::NativeReturnCode::NATIVE_RUNTIME_ERROR;
	}

	UFunction* Function = FunctionToCall.Get();
	FParamsBuffer Params(Function);

	int32 ParamIndex = 0;
	bool bFailedMatch = false;

	for (TFieldIterator<FProperty> It(Function); It; ++It)
	{
		FProperty* Prop = *It;
		if (!Prop->HasAnyPropertyFlags(CPF_Parm) || Prop->HasAnyPropertyFlags(CPF_OutParm | CPF_ReturnParm))
			continue;

		if (ParamIndex >= Args.size())
		{
			bFailedMatch = true;
		}

		if (!bFailedMatch)
		{
			void* DestPtr = Prop->ContainerPtrToValuePtr<void>(Params.Get());
			if (!SetPropertyFromFlexValue(Prop, DestPtr, Args[ParamIndex], ParamIndex, Function->GetName()))
				bFailedMatch = true;
		}

		ParamIndex++;
	}

	if (ParamIndex < Args.size())
	{
		bFailedMatch = true;
	}

	if (!bFailedMatch)
	{
		Object->ProcessEvent(Function, Params.Get());
		return GetReturnValueFromBuffer(Function, Params.Get());
	}
	else if (ParamIndex != Args.size())
	{
		UE_LOG(LogTemp, Warning, TEXT("Function %s expects %d parameters, but %d were provided."), *Function->GetName(), ParamIndex, Args.size());
	}

	return flex::NativeReturnCode::NATIVE_INVALID_ARGUMENTS;
}

What I Learned

This project taught me a lot about programming languages, compilers, Unreal Engine & working in large codebases.

  • By building a programming language, compiler & interpreter from scratch I’ve demystified how these tools work in the tools I use day-to-day. I’ve learnt more about call stacks, call frames, assembly & data types.
  • Making a custom C++ plugin for Unreal Engine has taught me more about UE and its underlying systems like Subsystems, DeveloperSettings, Delegates & Reflection.
  • Working in Unreal Engine has taught me how to work in a large, existing codebase. You learn to work with both the tools that it gives you and the constraints you face.

Future Improvements

  • Publishing plugin to FAB - This is a process I started, but haven’t completed yet. Will teach me more about documenting & packaging.
  • Allowing further language customization - Fully switch out keywords, implement it in your native language with full Unicode support.

Trending Tags