Back

Activity 1.11: Tables as Objects

divider

The Idea

Lua has no classes. No class keyword, no new keyword, nothing to import. What it has instead is one small rule that you can build objects out of, and this page is that rule.

Start from something you already know. A table can hold a function (Activity 1.7), and a table can hold data (Activity 1.10). Put both in the same table and you have most of an object.

main.lua
local ship = { name = "Skynest", charge = 50 }
function ship.report(self)
return self.name .. " at " .. self.charge .. "%"
end
print(ship.report(ship))
print(ship:report())
Output
Skynest at 50%
Skynest at 50%

Both lines printed the same thing, because ship:report() is exactly ship.report(ship). That is the whole of the colon. It passes the thing on the left as the first argument.

The same shorthand works when you define the function, and it is where self comes from — function Ship:report() is shorthand for function Ship.report(self). self is not a keyword. It is an ordinary parameter that the colon declared for you.

So mixing the two is the classic beginner error. Define with a dot and call with a colon and you get an extra argument nobody expected; define with a colon and call with a dot and self is nil. Both fail confusingly. Use the colon for both, always, and the problem disappears.

The one rule everything rests on

The table above works, but every ship would need its own copy of every function. What you want is for many tables to share one set of functions. That needs the rule.

__index means: if a key is not in this table, go look in that one.

main.lua
local Base = { greet = function() return "hi" end }
local obj = {}
print(type(obj.greet))
setmetatable(obj, { __index = Base })
print(type(obj.greet))
print(obj.greet())
Output
nil
function
hi

Before the setmetatable call, obj.greet was nil. Afterwards it finds the function — without anything being copied into obj. A metatable is just a table of rules attached to another table, and __index is the rule for failed lookups.

Note this is a fallback, not a merge. A key that exists on the object itself always wins, and writing to the object never touches the table it falls back to.

Putting it together: the class pattern

Every Lua "class" you will ever read is these four lines, so it is worth being able to write them from memory.

main.lua
local Ship = {}
Ship.__index = Ship
function Ship.new(name, charge)
return setmetatable({ name = name, charge = charge }, Ship)
end
function Ship:report()
return self.name .. " at " .. self.charge .. "%"
end
local a = Ship.new("Skynest", 50)
local b = Ship.new("Vagrant", 92)
print(a:report())
print(b:report())
Output
Skynest at 50%
Vagrant at 92%

Reading it one line at a time:

  • local Ship = {} — the "class" is an ordinary empty table. Capitalized only by convention.
  • Ship.__index = Shipthe line that does the work. It says: when a lookup misses, come back here. Ship is both the class and its own fallback table, which looks circular and is simply thrifty.
  • Ship.new — a plain function that makes a fresh table of data and attaches Ship as its metatable. Nothing about the name new is special. Note the dot, not a colon: you are not calling it on an instance, because there is not one yet.
  • function Ship:report() — a method, stored on the class, found by every instance through the fallback.

The data is per-instance and the methods are shared. Each ship's table holds only its own name and charge. One copy of report exists no matter how many ships you make — which is exactly what you want when Unit 2 has two hundred bullets on screen.

This or a closure?

Activity 1.8 built something with methods and private state using nothing but closures. Both approaches are real Lua, and they trade off against each other:

  • Closures give you genuine privacy — there is no way to reach the captured variable. But every instance carries its own copy of every function.
  • Metatables share one copy of each method across every instance, and cost one extra lookup on a miss. But the fields are all public; ship.charge = 9999 is nobody's business but works fine.

For games, metatables win, because you make a lot of instances and privacy is not the problem you have. That is what Unit 2 uses.

divider

Build

Start a fresh main.lua. You are turning the ship record from Activity 1.10 into something you can make many of.


Task 1: One table, one method

  • Make a single ship table with a name and a charge, then attach a report function that takes self explicitly.
  • Call it both ways — once with a dot passing the ship yourself, once with a colon.
  • Two identical lines is the proof that the colon is only shorthand.
main.lua
-- main.lua - Skynest ship objects
local ship = { name = "Skynest", charge = 50 }
function ship.report(self)
return self.name .. " at " .. self.charge .. "%"
end
print(ship.report(ship))
print(ship:report())
Output
Skynest at 50%
Skynest at 50%

Task 2: Make it a class

  • Rewrite it as a Ship table with Ship.__index = Ship and a Ship.new constructor.
  • Define report with a colon this time, and drop the explicit self parameter.
  • Make two ships with different values and report both.
  • Then try deleting the Ship.__index = Ship line and running it. The error you get names the exact thing that line was doing.
main.lua
-- main.lua - Skynest ship objects
local Ship = {}
Ship.__index = Ship
function Ship.new(name, charge)
return setmetatable({ name = name, charge = charge }, Ship)
end
function Ship:report()
return self.name .. " at " .. self.charge .. "%"
end
local skynest = Ship.new("Skynest", 50)
local vagrant = Ship.new("Vagrant", 92)
print(skynest:report())
print(vagrant:report())
Output
Skynest at 50%
Vagrant at 92%

Task 3: Add a method after the ships already exist

  • Below the two ships you already created, add a recharge method that adds to the charge.
  • Clamp it at 100 with math.min from Activity 1.4.
  • Recharge both ships by 25 and report them again.
  • Notice that it works on ships that existed before the method did. Nothing was copied into them when they were made — they look the method up when it is called, so adding one later reaches every instance already out there.
main.lua
-- main.lua - Skynest ship objects
local Ship = {}
Ship.__index = Ship
function Ship.new(name, charge)
return setmetatable({ name = name, charge = charge }, Ship)
end
function Ship:report()
return self.name .. " at " .. self.charge .. "%"
end
local skynest = Ship.new("Skynest", 50)
local vagrant = Ship.new("Vagrant", 92)
print(skynest:report())
print(vagrant:report())
function Ship:recharge(amount)
self.charge = math.min(100, self.charge + amount)
end
skynest:recharge(25)
vagrant:recharge(25)
print(skynest:report())
print(vagrant:report())

Challenge (Optional)

  • Add a Ship:isCharged() returning true at 80 or above, and use it in an if.
  • Give Ship.new a default charge of 100 when none is passed, using the or idiom from Activity 1.7.
  • Make a list of five ships with table.insert and report them all in one ipairs loop. This is the shape of every game object in Unit 2 — a list of instances, walked once per frame.
divider

Check Your Work

Your finished file should produce this.

Output
Skynest at 50%
Vagrant at 92%
Skynest at 75%
Vagrant at 100%

Vagrant stopped at 100, not 117. The clamp worked, both ships kept their own charge, and one shared method served both. That is object-oriented programming in Lua, and you did it with a table and one metatable field.