Skip to content

Instantly share code, notes, and snippets.

@amirrajan
Last active June 25, 2024 16:09
Show Gist options
  • Save amirrajan/2c42315ffef311600ecb2d8dcfe3ce88 to your computer and use it in GitHub Desktop.
Save amirrajan/2c42315ffef311600ecb2d8dcfe3ce88 to your computer and use it in GitHub Desktop.
mRuby vs Lua as a Scripting Layer in a Game Engine
enemy-encounter.mp4

Intro

Scripting layers are common within game engines. They provide a productive environment to write game logic in and are simple to embed. Lua has historically been the go to language as a scripting layer because of its embedding simplicity. There’s another language that’s worth considering: Ruby in its embeddable form -> mRuby.

Real World Example

This is an example from a real world game I’ve built. The game is a roguelike with enemy encounters. Here’s a visual demonstration of one of these encounters above.

The EncounterEvent has a number of properties that need to be specified which are used during the battle playing out. Here are some of the public properties

  • title: Name of the enemy.
  • text: Narrative of the enemy encounter.
  • damage: The amount of damage the enemy does per hit.
  • health: The hitpoints of the enemy.
  • attack_delay: The time between attacks.
  • hit: The accuracy of the enemy (used to determine if an attack misses or not).
  • loot: A collection of items dropped by the enemy, with probabilities for each item, and quantity dropped.

Class Definition

Here’s the Ruby class definition for the EncounterEvent:

class EncounterEvent
  attr_accessor :title, :text, :damage, :health,
                :attack_delay, :hit, :loot, :attack_result

  def initialize(title:, text:, damage:, health:, attack_delay:, hit:, loot:)
    @title = title
    @text = text
    @damage = damage
    @health = health
    @attack_delay = attack_delay
    @hit = hit
    @loot = loot
    @current_attack_delay = @attack_delay
  end

And here’s the Lua equivalent:

EncounterEvent = {}
EncounterEvent.__index = EncounterEvent

function EncounterEvent.new(title, text, damage, health, attack_delay, hit, loot)
   local self = setmetatable({}, EncounterEvent)
   self.title = title
   self.text = text
   self.damage = damage
   self.health = health
   self.attack_delay = attack_delay
   self.hit = hit
   self.loot = loot
   self.current_attack_delay = attack_delay
   return self
end

Note: Lua doesn’t have named parameters, so the parameters are passed in order. A table could be used to pass in the parameters, but it’s not as clean as named parameters.

Inheritance of EncounterEvent

Here’s the Ruby class definition for the SnarlingBeast:

# SnarlingBeast inherits from EncounterEvent
class SnarlingBeast < EncounterEvent
  def initialize
    super(
      title: "Snarling Beast",
      text: "A snarling beast appears!",
      damage: 10,
      health: 100,
      attack_delay: 60,
      hit: 0.8,
      loot: {
        fur:   { min: 1, max: 10, chance: 0.8 },
        teeth: { min: 1, max: 10, chance: 0.8 },
      }
    )
  end
end

# initialization
beast = SnarlingBeast.new
loot = beast.random_loot

And here’s the Lua equivalent:

function SnarlingBeast:new()
   local self = setmetatable(
      EncounterEvent:new("Snarling Beast", "A snarling beast appears!",
                         10,
                         100,
                         60,
                         0.8,
                         {
                            fur = {min = 1, max = 10, chance = 0.8},
                            teeth = {min = 1, max = 10, chance = 0.8},
                         }
      ), SnarlingBeast)
   return self
end

-- initialization
local beast = SnarlingBeast:new()
local loot = beast:random_loot()

Retrieving Random Loot

Here is the Ruby implementation for retrieving random loot:

def random_loot
  # enumerate the loot hash and:
  # - do a random roll against the loot chance
  # - for loot that succeeds RNG
  # - use the loot's min/max quantities and roll for loot amount
  # - sort loot by name
  @loot.find_all do |key, value|
    rand <= value[:chance]
  end.map do |key, value|
    {
      name: key,
      quantity: rand(value[:max]) + value[:min]
    }
  end.sort_by do |loot|
    loot[:name]
  end
end

And here’s the Lua equivalent:

function EncounterEvent:random_loot()
    local loot_table = {}
    for key, value in pairs(self.loot) do
        if math.random() <= value.chance then
            local quantity = math.random(value.min, value.max)
            table.insert(loot_table, {name = key, quantity = quantity})
        end
    end
    table.sort(loot_table, function(a, b) return a.name < b.name end)
    return loot_table
end

Observations of Retrieving Random Loot

Ruby’s enumerable methods allow for filtering, mapping, and sorting to be seperated out into different methods. You’ll notice that the Lua version checks the loot drop chance and quantity in the same loop.

This can also be done in Ruby version like so:

def random_loot
  @loot.map do |key, value|
    if rand <= value[:chance]
      {
        name: key,
        quantity: rand(value[:max]) + value[:min]
      }
    end
  end.compact
end

The blocks for filter, map, and sort in the Ruby version communicates a seperation of concerns. If you wanted to do the same in Lua, it would look something like this:

function EncounterEvent:filter_loot(loot)
    local filtered_loot = {}
    for key, value in pairs(loot) do
        if math.random() <= value.chance then
            table.insert(filtered_loot, {name = key, min = value.min, max = value.max})
        end
    end
    return filtered_loot
end

function EncounterEvent:map_quantities(filtered_loot)
    for i, loot in ipairs(filtered_loot) do
        loot.quantity = math.random(loot.min, loot.max)
        loot.min, loot.max = nil, nil -- Delete min and max from the table
    end
end

function EncounterEvent:random_loot()
    local filtered_loot = self:filter_loot(self.loot)
    self:map_quantities(filtered_loot)
    table.sort(filtered_loot, function(a, b) return a.name < b.name end)
    return filtered_loot
end

Which is a bit more verbose than the Ruby version. You’ll also notice that the Lua version mutates the loot table by deleting the min and max keys after the quantity is calculated. This is to keep the loot table clean and only contain the name and quantity, but it’s a bit of a side effect of the implementation.

Ruby allows for function block definitions to use do and end or curly braces. This is what the Ruby version would look like with curly braces:

def random_loot
  @loot.find_all { |key, value| rand <= value[:chance] }
       .map { |key, value| Hash[name: key, value: value[:value]] }
       .sort_by { |loot| loot[:name] }
end

Note: Take a moment to internalize this difference. The 3 lines of Ruby code are equivalent to the 23 lines of Lua code.

Filter/Map Complexity Add Up

The lack of consice block definitions in Lua is a bit of a downside. Especially when the complexity of the filters and maps increase.

Here is an example of finding Pythagorean Triples of all Trangles with sides of a length and width between 1 and 20.

  • A Pythagorean Triple is a set of three integers a, b, and c such that a^2 + b^2 = c^2.
  • The function checks all permutations of a and b between 1 and 20.
  • It removes duplicates based on the sorted sides.
  • The area of the triangle is calculated and the triples are sorted by area.
one_to_twenty = (1..20).to_a

triples = one_to_twenty.product(one_to_twenty)
                       .map do |a, b|
                         # given permutations of side a, and side b (product)
                         # calculate the hypotenuse
                         { a:, b:, c: Math.sqrt(a ** 2 + b ** 2) }
                       end.find_all do |triangle|
                         # where area is a whole number (pythagaroean triple)
                         triangle[:c].to_i == triangle[:c]
                       end.uniq do |triangle|
                         # unique based on sorted sides
                         triangle.values.map(&:to_i).sort
                       end.map do |triangle|
                         # calculate the area
                         triangle.merge(area: (triangle[:a] * triangle[:c]) / 2)
                       end.sort_by do |triangle|
                         # sort by area
                         triangle[:area]
                       end

puts triples

# {:a=>3, :b=>4, :c=>5.0, :area=>7.5}
# {:a=>6, :b=>8, :c=>10.0, :area=>30.0}
# {:a=>5, :b=>12, :c=>13.0, :area=>32.5}
# {:a=>9, :b=>12, :c=>15.0, :area=>67.5}
# {:a=>8, :b=>15, :c=>17.0, :area=>68.0}
# {:a=>12, :b=>16, :c=>20.0, :area=>120.0}
# {:a=>15, :b=>20, :c=>25.0, :area=>187.5}

The Lua version isn’t something I’d want to write.

Cleaning Up Class Initialization using Ruby’s Metaprogramming Capabilities

Ruby has a powerful metaprogramming capability that allows for the initialization of the derived class to be simplified. While these metaprogramming capabilities take some time to get used to, they can be very powerful.

Here’s the cleaned up version of the SnarlingBeast class definition:

class SnarlingBeastEvent < EncounterEvent
  title "a snarling beast"
  text  "a snarling beast leaps out of the underbrush."
  damage 1
  health 5
  attack_delay 1
  hit 0.8
  loot name: :fur, min: 1, max: 3, chance: 1
  loot name: :meat, min: 1, max: 3, chance: 1
  loot name: :teeth, min: 1, max: 3, chance: 0.8
end

This is done by defining class methods that set the properties of the class.

Here is the original Lua version:

function SnarlingBeast:new()
   local self = setmetatable(
      EncounterEvent:new("Snarling Beast", "A snarling beast appears!",
                         10,
                         100,
                         60,
                         0.8,
                         {
                            fur = {min = 1, max = 10, chance = 0.8},
                            teeth = {min = 1, max = 10, chance = 0.8},
                         }
      ), SnarlingBeast)
   return self
end

We can even have Ruby create a class instance of SnarlingBeast similar to how Lua does it:

EncounterEvent.create :SnarlingBeast,
                      title: "a snarling beast",
                      text: "a snarling beast leaps out of the underbrush.",
                      enemy: :snarling_beast,
                      damage: 1,
                      health: 5,
                      attack_delay: 1,
                      hit: 0.8,
                      loot: {
                        fur:   {  min: 1, max: 3, chance: 1 },
                        meat:  { min: 1, max: 3, chance: 1 },
                        teeth: { min: 1, max: 3, chance: 0.8 }
                      }

# initialization
beast = SnarlingBeast.new

# behind the scenes/how this is accomplished
class EncounterEvent
  def self.create encounter_name, opts
    Object.const_set encounter_name, Class.new(EncounterEvent) do
      def initialize
        super opts
      end
    end
  end
end

Limitations of Language Capabilities Add Up

It’s extremely important not to dismiss the limitations of a language. As complexity of your game increases, the limitations of the language will become more apparent.

Lua lacks many OO features (such as inheritance), lacks function block definitions, and lacks metaprogramming capabilities among other things.

These limitiations mean more code to write, more code to maintain, and more time till a release. I can’t stress this enough and if I were to have chosen Lua for my game, the 20,000 lines of Ruby would have been 40,000 lines of Lua.

This write up is worth reading through: https://paulgraham.com/avg.html

With respect to language comparisons, the Blub Paradox is worth mentioning and being aware of:

Blub Paradox

I’m going to use a hypothetical language called Blub. Blub falls right in the middle of the abstractness continuum. It is not the most powerful language, but it is more powerful than Cobol or machine language.

And in fact, our hypothetical Blub programmer wouldn’t use either of them. Of course he wouldn’t program in machine language. That’s what compilers are for. And as for Cobol, he doesn’t know how anyone can get anything done with it. It doesn’t even have x (Blub feature of your choice).

As long as our hypothetical Blub programmer is looking down the power continuum, he knows he’s looking down. Languages less powerful than Blub are obviously less powerful, because they’re missing some feature he’s used to. But when our hypothetical Blub programmer looks in the other direction, up the power continuum, he doesn’t realize he’s looking up.

Cognitive Dissonance

What he sees are merely weird languages. He probably considers them about equivalent in power to Blub, but with all this other hairy stuff thrown in as well. Blub is good enough for him, because he thinks in Blub.

When we switch to the point of view of a programmer using any of the languages higher up the power continuum, however, we find that he in turn looks down upon Blub. How can you get anything done in Blub? It doesn’t even have y.

Don’t Take My Word For It, Try Out Ruby For Yourself

By induction, the only programmers in a position to see all the differences in power between the various languages are those who understand the most powerful one. You can’t trust the opinions of the others, because of the Blub paradox: they’re satisfied with whatever language they happen to use, because it dictates the way they think about programs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment