-
-
Save amirrajan/2c42315ffef311600ecb2d8dcfe3ce88 to your computer and use it in GitHub Desktop.
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.
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.
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.
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()
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
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.
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.
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
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:
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.
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.
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.