I'm an ex-long time "AAA" game programmer, now solo indie developer making GearBlocks, a game all about creative building, interactive machines, and gears - lots of gears!

Report RSS Scenario mode and Lua scripting

Posted by on

Hey everyone, over the past month I've been back to working on the scenario mode. One of the last things I want to get done before the initial early access release is to add some in-game tutorials, and I think it makes most sense to do this using the scenario system.

I figured the best way for the scenario mode to be flexible enough to support tutorials, as well as other kinds of challenges, would be to do it via dynamic scripting. So for each scenario there'd be a script that gets loaded at run-time, hooks into the game, and implements the tutorial / challenge logic. This would allow for user created challenges and mini-games that players can share on the workshop.

Choosing a scripting language

The first step was to choose what scripting language to use. This had me thinking about modding more broadly, it made sense to keep this in mind when deciding on a scripting approach.

A lot of Unity games with scripting support for mods do it by loading assemblies at run-time, which works because the Mono back-end uses JIT compilation. This is quite an open ended option for modders, they can write scripts in C# and use whatever assemblies are available from the game.

However, I'm using the IL2CPP back-end to eke out as much performance as possible, which by its nature is an AOT compiler and cannot load arbitrary assemblies at run-time. So, I needed to use some kind of run-time interpreter.

I narrowed it down to two options and spent some time with both to evaluate them in the game:-

C# using Roslyn C#

  • This is a Unity asset that provides run-time compilation of C# scripts.
  • It doesn't work with IL2CPP directly (the JIT vs AOT thing again), but there's the dotnow CIL interpreter which integrates with it and executes CIL on IL2CPP platforms.
  • However, dotnow is in early development, and has some inherent limitations.
  • I came up against some problems with dotnow, nothing that couldn't be worked around, but it's definitely a more involved process.
  • The C# language is overkill really for creating scenarios, but might make sense for modding the game more generally.

Lua using the MoonSharp interpreter

  • This is an interpreter written in C#, no JIT, so it works with IL2CPP.
  • It's been around for a while, development doesn't seem that active, but I think it's pretty stable at this point.
  • I found hooking up Lua scripts into the game with MoonSharp to be quick and easy.
  • Lua is more than sufficient for making scenarios, and I think would be simpler to learn for players wanting to create their own.

After this investigation and considering the points mentioned above, I decided to go with the Lua option, at least for the scenario mode and other basic mods. If I ever decide to support more complex modding (e.g. adding new part behaviours or other direct interactions with the physics engine), then C# would probably make more sense, but this will be something to revisit in the future.

Loading and running Lua scripts

I implemented a Lua script "manager" to handle loading Lua script files from any specified path, unloading of scripts, and to keep track of the lifetime of active scripts. It's the only code in the game that directly references the MoonSharp interpreter, which it uses to configure and execute the scripts.

The manager also calls two specific global functions in each active Lua script if they are defined, "Update" and "Cleanup". The former is called every game update, and the latter is called on a script just before it is unloaded.

Lastly, the manager provides functions for other game code to register types and object instances with MoonSharp. This is what enables Lua scripts to interface with game code and do useful things.

Events and variables

The GearBlocks code architecture is built extensively around ScriptableObjects, particularly for events and global variables which are used to communicate between separate subsystems. I covered this in some detail here if you're interested: ScriptableObjects For Fun and Profit.

Not only was this approach useful for decoupling game code, but it also made it easy to add a debug console that allows for tweaking variables and raising events from within the game for testing and experimentation purposes. There's more info on this here: Debug console.

It was similarly easy to expose these same events and variables to Lua scripts. Each event and variable ScriptableObject asset now registers its type and instance with the Lua script manager. This lets Lua scripts read / write variables, and raise / handle events. It means a Lua script can configure the game for a particular scenario, for example a tutorial could disable certain game features, and then unlock them as the player progresses through the tutorial.

Here's a basic Lua script registering an event handler and modifying a game variable, note the use of the Cleanup function to remove the event handler:

local function onGameReady()
    -- The game is ready, so initialise stuff here.
    ShowFPS.value = true -- Turn on the FPS display.
end

-- Add a handler to the GameReady event.
GameReady.handler.add( onGameReady )

function Cleanup()
    -- Script is about to be unloaded, so cleanup here.
    ShowFPS.value = false -- Turn off the FPS display.
    GameReady.handler.remove( onGameReady ) -- Remove the event handler.
end

I added two new events (handled by the Lua script manager) for loading and unloading Lua scripts. These events are called by the game's UI code when starting a new scenario mode game to load the Lua script for that scenario, and to unload it when finished. Because these events are available in the debug console, it's easy to load and unload Lua scripts while the game is running, and it also allows players to run their own custom scripts to mod the game!

Proxies

Some of the events and variables now available to Lua scripts depend on Unity types: Vector3 and Color32. Rather than expose these directly to Lua, I created proxies for them - wrappers that contain an instance of the relevant type, and expose a subset of their properties and methods. These proxy types are registered with the script manager to be made available in Lua scripts, both for using with the associated events and variables, and also for using the types directly (e.g. for doing vector math operations).

Input

In order for Lua scripts to access player input, the relevant game interfaces and the input system are now registered with the Lua script manager. This exposes the enums for input action IDs, and functions for finding if the key bound to an action is triggered or held.

Here's an example Lua script that checks if a particular input action is triggered, note the use of the Update function to poll the input:

local function onGameReady()
    -- The game is ready, so initialise stuff here.
    ShowFPS.value = true -- Turn on the FPS display.
end

-- Add a handler to the GameReady event.
GameReady.handler.add( onGameReady )

function Update()
    -- For no good reason lets toggle the FPS display
    -- whenever the jump key is pressed!
    if InputActions.isTriggered( actionID_Jump ) then
        ShowFPS.value = not ShowFPS.value
    end
end

function Cleanup()
    -- Script is about to be unloaded, so cleanup here.
    ShowFPS.value = false -- Turn off the FPS display.
    GameReady.handler.remove( onGameReady ) -- Remove the event handler.
end

Custom user interfaces

Finally, I've implemented a basic UI system that registers itself with the Lua script manager, and allows Lua scripts to create their own custom user interfaces. This is required for tutorials to relay instructions to the player and so on, but it'll also be useful for creating custom tools and mods in the game.

A script can create a UI window (or multiple windows if needed), and then add UI elements to it. So far I've implemented text label and button elements, but I'll be adding more as I go.

Here's an example Lua script that creates a window and adds a text label and a button to it, note the alignment options I've exposed to Lua scripting for positioning and sizing the window and the elements within it:

local function onTextButtonClick()
    print( 'Text button was clicked' )
end

local function onGameReady()
    -- The game is ready, so initialise stuff here.

    -- Create a UI window.
    Win = Windows.createWindow()
    Win.setAlignment( align_RightEdge, 20, 200 ) -- Width 200, offset 20 from right edge of screen.
    Win.setAlignment( align_TopEdge, 80, 250 )   -- Height 250, offset 80 from top edge of screen.
    Win.title = 'Test window'
    Win.isDraggable = true
    Win.show( true )

    -- Add a text label.
    Label = Win.createLabel()
    Label.setAlignment( align_HorizEdges, 10, 10 ) -- Offset 10 from left and right edges of window.
    Label.setAlignment( align_VertEdges, 50, 10 )  -- Offset 50 from bottom edge and 10 from top edge of window.
    Label.alignment = textAnc_MiddleCenter
    Label.fontSize = 18
    Label.text = 'Hello world'

    -- Add a text button.
    TextButton = Win.createTextButton()
    TextButton.setAlignment( align_HorizCentre, 0, 100 ) -- Width 100, centered horizontally in window.
    TextButton.setAlignment( align_BottomEdge, 10, 30 )  -- Height 30, offset 10 from bottom edge of window.
    TextButton.onClick.add( onTextButtonClick )
    TextButton.text = 'Click me'
end

-- Add a handler to the GameReady event.
GameReady.handler.add( onGameReady )

function Cleanup()
    -- Script is about to be unloaded, so cleanup here.
    Windows.destroyWindow( Win ) -- Destroy the UI window.
    GameReady.handler.remove( onGameReady ) -- Remove the event handler.
end

And here's the result when you run the script in game:

OK that's it, thanks for reading if you made it this far! Right now I'm starting to rough out some tutorial scenarios, and I'll continue to extend the Lua scripting functionality as required for this, hopefully I'll have another progress update on this soon.

Post a comment

Your comment will be anonymous unless you join the community. Or sign in with your social account: