LunyScript API: User Extensions

I have published a design article about how to extend the LunyScript API. I’ll provide a brief summary here.

A Basic LunyScript

First, an up-to-date example of the current LunyScript API.

This script logs a string every update. It’s complete as is, nothing missing. To make it work, the user merely has to have an object of the same name LogUpdate in the active scene and it will start logging every update.

public class LogUpdate : LunyScript.LunyScript
{
    public override void Build()
    {
        When.Self.Updates(Debug.Log("updating"));
    }
}

There are generally three ways to add your own API to LunyScript in such a way that it feels ā€œnativeā€.

Static Factory Class (easiest)

To share scripts with others frictionless, you can write a static class with API methods without a namespace:

public static class Inventory
{
    public static IScriptActionBlock AddCoins(int coins) 
        => InventoryAddCoinsBlock.Create(coins);
}

This API is automatically usable in any LunyScript-derived class in the same project:

// Usage in a LunyScript
public class Player : LunyScript.LunyScript
{
    public override void Build()
    {
        When.Self.Updates(Inventory.AddCoins(1));
    }
}

Static factories are very beginner-friendly and share easily.

However they do appear everywhere in autocompletion suggestions (Intellisense). In fact, users can call the static method even outside of LunyScript instances.

Common Base Class (ā€˜internal use’ only)

For a frightening large number of developers, subclassing is the first (and only) thing they can think of. Please learn about composition if you inherit-by-default. ;)

The subclassing approach follows the same fluent API pattern LunyScript uses via properties and stack-allocated API implementations:

public abstract class OurLunyScriptBase : LunyScript.LunyScript
{
    public InventoryApi Inventory => new InventoryApi();
}

The implementation of the API is in a readonly struct (no garbage) and ideally in the same file if not a nested type of OurLunyScriptBase:

public readonly struct InventoryApi
{
    public IScriptActionBlock AddCoins(int coins)
        => InventoryAddCoinsBlock.Create(coins);
}

You can then subclass from OurLunyScriptBase and use your own API:

// Usage in a LunyScript
public class Player : OurLunyScriptBase
{
    public override void Build()
    {
        When.Self.Updates(Inventory.AddCoins(2));
    }
}

The subclassing solution is not sharing-friendly and requires all extension code to route through a single base type.

Its a workable solution for a studio or faculty at most, but forcing other users to inherit from your base class is intrusive and locks them in.

Please don’t share base-class API extensions with the public!

This is the preferred, professional way to extend LunyScript. Instead of a subclass defining the API property you now write an equally straightforward interface tagged with the [LunyScriptExtension] attribute for the Roslyn generator:

[LunyScriptExtension] // <== declares plugin interface
public interface IInventory
{
    InventoryApi Inventory => new InventoryApi();
}

The API implementation remains the same but is tagged with the [LunyScriptApi] attribute:

[LunyScriptApi] // <== declares struct with API extensions
public readonly struct InventoryApi
{
    public IScriptActionBlock AddCoins(int coins)
        => InventoryAddCoinsBlock.Create(coins);
}

You can then use the extension API by implementing the IInventory interface in a partial LunyScript-derived class. API usage is identical to the first two examples thanks to Roslyn generators:

public partial class Player // 'partial' required for Roslyn 
    : LunyScript.LunyScript, IInventory // <== extension interface
{
    public override void Build()
    {
        When.Self.Updates(Inventory.AddCoins(4));
    }
}

Roslyn will inject an Inventory property into Player to make use of the extension API feel ā€˜native’. The property casts this to the IInventory interface and returns the API type:

    // property generated by Roslyn, injected into 'Player':
    InventoryApi Inventory => ((IInventory)this).Inventory;

In scripts not using the IInventory interface, the Inventory API will not appear in autocompletion suggestions. The partial keyword is only required when adding an extension interface, and will have a custom error message and code fix if it’s missing.

These interface and API implementations can easily be shared with others, and they can choose per script which extension APIs to use. Highly recommended for public sharing!

Conclusion

I could have just mentioned the preferred solution but I wanted to get across the opportunities for teaching LunyScript provides even outside the scope of its API. With just these examples you can cover a wide range of design, C# language and user experience (IDE) topics:

  • fluent API design
  • composition vs inheritance (OOP coupling, diamond problem, by example: Godot vs others)
  • interface mixins and why interfaces require casting ā€˜this’
  • static classes, static factories, C# extension methods
  • UX type discovery (autocompletion, not completing ā€˜this.’, static == visible everywhere)
  • abstract base classes, nested types, namespaces
  • struct vs class => stack vs heap allocations

I’m looking forward to those instructive lessons. :)