27. Simple OOP with metatables

Chapter 26 put a few methods directly on a table. That works for one object, but wastes effort for many objects of the same shape. Imagine 100 dogs — copying bark onto each one is silly. This chapter introduces the metatable, then uses it to write one method table that every instance shares.

This is also where Roblox-style code starts to look familiar. A Roblox Part, Player, and Tool all follow this pattern.

A class with .new and :method

Here is the standard Lua "class" pattern. Read it twice, then check the explanation:

local Point = {}
Point.__index = Point

function Point.new(x, y)
    local self = setmetatable({}, Point)
    self.x = x
    self.y = y
    return self
end

function Point:distance(other)
    local dx = self.x - other.x
    local dy = self.y - other.y
    return math.sqrt(dx * dx + dy * dy)
end

local a = Point.new(0, 0)
local b = Point.new(3, 4)
print(a:distance(b))   -- 5.0

What is going on:

  1. Point = {} creates the class table — every method lives here, defined once.
  2. Point.__index = Point is the magic line. It tells Lua: if a key lookup on an instance fails, also check here. The metatable is Point itself, its __index pointing back to itself.
  3. Point.new(x, y) is a regular function (note the dot, not the colon). It creates an instance — an empty table — sets its metatable to Point, fills the fields, and returns it.
  4. setmetatable({}, Point) attaches the metatable, so the empty table now knows: for missing keys, look inside Point.
  5. function Point:distance(other) defines the method with the colon, making self the first parameter — same as chapter 26.
  6. a:distance(b) is the call site. a is an instance, so Lua checks a for distance. Not found, so __index redirects the lookup to Point, which has it. The method runs with self = a and other = b.

The result: one method table, many small instances, no copying.

Open exercises/27/01-point.lua. Add a Point:move(dx, dy) method that adds dx to self.x and dy to self.y. Create a point, move it twice, then print its position.

Why the metatable indirection?

A class needs a metatable, not a plain table, because assignment to an instance must not affect the class.

Skip the metatable and write local self = Point, and self.x = 0 writes x = 0 onto the Point class itself. Every "instance" would share the same x — not what "instance" means.

setmetatable({}, Point) gives a fresh empty table per instance. Writes go into the instance; reads fall through to the class only when the instance lacks the key.

A second class: Character

A small character class that can take damage:

local Character = {}
Character.__index = Character

function Character.new(name, hp)
    local self = setmetatable({}, Character)
    self.name = name
    self.hp = hp
    self.max_hp = hp
    return self
end

function Character:takeDamage(amount)
    self.hp = self.hp - amount
    if self.hp < 0 then
        self.hp = 0
    end
end

function Character:heal(amount)
    self.hp = self.hp + amount
    if self.hp > self.max_hp then
        self.hp = self.max_hp
    end
end

function Character:isAlive()
    return self.hp > 0
end

local c = Character.new("Keiko", 100)
c:takeDamage(30)
print(c.hp)            -- 70
c:heal(50)
print(c.hp)            -- 100   (capped at max_hp)
print(c:isAlive())     -- true

Each Character.new(...) produces a fresh instance with its own name, hp, and max_hp. The four methods live on Character; every instance reaches them through __index.

Inheritance with two __index hops

A child class inherits from a parent by giving the child's metatable its own __index chain. The two short tables:

local Animal = {}
Animal.__index = Animal

function Animal.new(name)
    local self = setmetatable({}, Animal)
    self.name = name
    return self
end

function Animal:describe()
    print("I am " .. self.name .. ".")
end

-- Dog inherits from Animal
local Dog = setmetatable({}, { __index = Animal })
Dog.__index = Dog

function Dog.new(name)
    local self = Animal.new(name)        -- build with Animal's fields
    return setmetatable(self, Dog)       -- but with Dog as metatable
end

function Dog:bark()
    print(self.name .. ": Woof!")
end

local rex = Dog.new("Rex")
rex:describe()    -- I am Rex.   (inherited from Animal)
rex:bark()        -- Rex: Woof!  (defined on Dog)

The key line is local Dog = setmetatable({}, { __index = Animal }). It says: when a lookup falls through Dog, fall through again into Animal. So rex:describe() checks rex, misses; checks Dog, misses; checks Animal, finds it.

That is enough inheritance for most purposes. Metatables can do more (operator overloading, custom comparison, callable tables), but these patterns cover everything Part 6's mini-project and the Roblox bridge need.

Homework

Problem 1 — Point class with move

Open exercises/27/homework/01-point.lua. Build a Point class as above, plus a Point:move(dx, dy) method that adds the deltas to the position. Test by creating two points, moving one, then printing the distance between them.

Problem 2 — Character class

Open exercises/27/homework/02-character.lua. Build the Character class with .new(name, hp), :takeDamage(amount), :heal(amount), and :isAlive(). Add a :report() method that prints the name and current HP. Walk through a small fight: damage twice, heal once, and print the report after each action.

Problem 3 — Rectangle class

Open exercises/27/homework/03-rectangle.lua. Build a Rectangle class with .new(width, height), :area(), and :perimeter(). Test it with two rectangles of different sizes.

Challenge — Animal and Dog

Open exercises/27/homework/04-inheritance.lua. Build Animal and Dog exactly as in the chapter's inheritance example. Then add a second child class Cat that also inherits from Animal and has its own :meow() method. Create one dog and one cat, call :describe() on both, then :bark() on the dog and :meow() on the cat.

Stuck or finished? Open the homework solutions page.