Note: This is the first of two articles on integrating C# and scripting languages. You can read the second part (on Javascript) here.
I recently created an engine (I know, I know) to create text-based Interactive Fiction games in C#. My requirement was simple: create a core engine with some core functionality (rooms, commands, and objects) in .NET, and allow end-users to write their games in Ruby. In the process, I picked up IronRuby as the vehicle of interaction.
Let’s start with my requirements: I should be able to write core functionality classes in C#, instantiate and extend them in IronRuby, and return them back to C# for consumption by the game engine. I also need strong typing of objects (despite the dynamic nature of scripting languages), and the ability to create delegates or functions inside my scripting language (and return those to C#, too).
You can view the source code on GitHub. This post summarizes my implementation, and the challenges I ran though.
RubyRunner Class
I created a core “RubyRunner” class which encapsulates all the Ruby-centric invocation. It contains a simple constructor, and a method public T Execute<T>(string script, IDictionary<string, object> parameters)
.
The first parameter (the script) is the easiest part. The second parameter contains any variables from C# that I want to expose to Ruby (this makes up my “API” which users write against).
One issue I ran into is a little error that can't convert Meltdown.Core.Area to Meltdown.Core.Area
.
Huh?
It’s a strange kink in IronRuby; I can include
my assembly to instantiate objects from it, but they don’t have the same type as the CLR equivalents (despite being loaded from the same assembly). The solution was to call load_assembly 'Meltdown.Core'
in every single Ruby code. I encapsulated this into a Common.rb
script which is auto-included in every invocation of Execute
.
Here’s how that looks, as of writing:
public RubyRunner()
{
if (File.Exists(@"Ruby\Common.rb"))
{
this.commonHeaderScript = File.ReadAllText(@"Ruby\Common.rb");
}
}
public T Execute<T>(string script, IDictionary<string, object> parameters)
{
var scope = engine.Runtime.CreateScope();
foreach (var kvp in parameters)
{
scope.SetVariable(kvp.Key, kvp.Value);
}
var finalScript = string.Format("{0}\n{1}", this.commonHeaderScript, script);
var toReturn = engine.Execute(finalScript, scope);
if (toReturn is T)
{
return (T)toReturn;
}
else
{
throw new ArgumentException("Expected an instance of " + typeof(T).FullName + " but got " + toReturn.GetType().FullName + " instead.");
}
}
Converting IronRuby Arrays to C# Lists
The second major hurdle is that IronRuby returns a RubyArray
instance instead of an enumeration if I use typical Ruby list syntax, like this: $x = ['a', 17, 2.0 / 3]
.
To get around this, I created a ScriptHelper
class. This class contains two methods: IsArray
and ToList<T>
. In both methods, I rely on IronRuby-specific implementation, and since the object input is dynamic
, I can just call Ruby methods and get back what I want. Here’s how that looks:
public static List<T> ToList<T>(dynamic source)
{
var found = new List<T>();
for (int i = 0; i < source.Count; i++)
{
object next = source[i];
found.Add(GetAsType<T>(next));
}
return found;
}
public static bool IsArray(dynamic source) {
return source.GetType().Name == "RubyArray";
}
This allows me to write “Ruby-like” code without worrying about types.
Returning Delegates/Functions
The last hurdle was related to functions; given a delegate or function type (like Action
), how can I return an object which I can type to a delegate type?
The answer was surprisingly easy; I just need to return an instance of Proc
from my Ruby code. Something like:
Proc.new {
puts "Action invoked. Yay!"
}
Conclusion
Given my requirements, I achieved them, and have code that allows me to write very “Ruby-like” code. That’s great; I can leverage C# to take care of some of the leaky abstractions, and everything works.
I suggest you download and play with the full source code from GitHub. You can look at the ScriptRunner.Tests
project to see working Ruby code coming back to C#.