This is a tutoral on using metatables to create wrappers around Roblox’s locked objects, such as Instances and special data types. This gives you the freedom to change or add new functionality to the existing API. The following guide assumes you’re comfortable with Lua, particularly with metatables, and are familiar with the direction Roblox’s object-based scripting tends to go.
I’ll preface this with a disclaimer: This isn’t a very good idea. Using Roblox’s API the way it was intended helps with performance, readability, and just about everything else. It’s possible that you have a very specific use case, but it’s a lot more possible that you don’t.
Don’t let that dissuade you though. This is fun. In addition, it can provide some good mental exercise, and it improves your grasp on what Roblox’s objects are actually doing. The disclaimer is only there because some people get picky about what’s practical and what isn’t. I think that sometimes it’s fine to be impractical.
Let’s get started with a quick overview of why this works and the steps necessary to do it. Or if you’re an expert and you hate reading, just scroll down for code samples.
The first thing to recognize is that what we’re about to do is basically already what Roblox objects are doing on a slightly lower level. Take, for example, a Part instance called Baseplate.
The actual Lua type for this object is a userdata. It’s called this because it can store any arbitrary data inside it defined by the user. An example of that data is the struct that makes up the Part instance, which is used by Roblox’s internal (C++) engine code. The user in userdata, in this case, is Roblox.
The other reason it’s called userdata is because it has built-in support for metatables. That’s where all the functionality comes from. As Roblox’s engine code is in C++, and the engine functions that are making use of the data in the Baseplate instance are also written in C++, there needs to be a way for Lua interactions to trigger those functions. All of that is done through metatables, and through the same metamethods we can assign from our side in normal scripts.
I was going to put an infographic here but I don’t think it’s necessary. If anyone has trouble with this part though please let me know. It’s not too important for the method but I like going over this stuff to make sure we’re always on the same page.
It’s clear now why Roblox metatables are locked. Their metamethods are C functions and we can’t really write our own implementations. What we’re doing in this method is creating a third layer—another metatable object—in which the real userdata making up the Roblox object acts as the core. We have to make it so that everything we do to this proxy object will reflect on the real object, and anything the real object returns will be also returned by the proxy as another proxy object.
We can actually create new userdatas from the Lua side using the newproxy function. Some people know this already, but some people don’t. These proxy userdatas are empty and you don’t get to store arbitrary data in them like you can when creating userdatas from C++, but by passing true
you get an unlocked metatable to create whatever functionality you want on the object. And we could use Lua tables and other values with those metatables to create our own pseudo-storage if we wanted to.
I saw another tutorial on here for doing OOP stuff with custom objects and pseudo-classes. I didn’t read through it all the way, but I’m pretty sure anyone who followed that is familiar with this step. Instead of creating our own functionality though, we are going to make it into a mirror for a real object.
Let’s go back to the Baseplate example from before. If you want to follow along, create a Part called Baseplate and put it in Workspace. Let’s do a whole bunch of stuff with it in a script as a goal/benchmark for our fake version.
local Baseplate = game.Workspace.Baseplate
--can we get basic stuff from it?
print(Baseplate, Baseplate.Name, Baseplate.ClassName)
--can we set properties?
Baseplate.Anchored = false
--let's call a custom method that doesn't exist in the real Part class
Baseplate:TellMeAJoke()
--let's try to break the wrapper and get a non-wrapped reference
Baseplate = Baseplate.Parent.Baseplate
Baseplate:TellMeAJoke()
--same thing, but stepping it up with a method call
Baseplate = Baseplate.Parent:FindFirstChild("Baseplate")
Baseplate:TellMeAJoke()
--let's do something funky: cloning the Part by using the unary minus operator (-)
NewBaseplate = -Baseplate
--messing with the cloned Part
NewBaseplate.Parent = game.Workspace
NewBaseplate.Name = "Otherplate"
NewBaseplate.Anchored = true
NewBaseplate.Size = Vector3.new(50,50,50)
--is it wrapped?
NewBaseplate:TellMeAJoke()
--help NewBaseplate reign supreme
Baseplate:Destroy()
If we run this by itself, we’ll get to the third test before an error is thrown. To build our custom Baseplate that’s capable of calling the custom method and more, we need to wrap it. Let’s create the most basic possible wrapping function now:
wrap = function(real) --we pass a real object, and it will spit out a fake copy
if type(real) == "userdata" then --this is all we should worry about for now
local fake = newproxy(true)
local meta = getmetatable(fake)
meta.__index = function(s,k)
--when we get things from the fake object, they should also be fake objects
return wrap(real[k]) --that's why this recursion is essential
end
meta.__newindex = function(s,k,v)
--allowing us to set values
real[k] = v
end
meta.__tostring = function(s)
--the more it behaves like the real object, the better
return tostring(real)
end
--meta.__etc...
--you can implement as many of these as necessary
--when you're done, return the new fake object
return fake
--elseif...
else
--remember that everything we get from the object will be passed through this function
--that includes non-userdatas, such as strings, tables, functions and more
--some of these things we will eventually need handlers for, and some we won't
--if this else block is ever reached though, we can just return the object without doing anything
return real
end
end
Now, if we use this function to make a fake reference to Baseplate, we can get a couple lines into our test before it errors:
local Baseplate = wrap(game.Workspace.Baseplate)
print(Baseplate, Baseplate.Name, Baseplate.ClassName)
Baseplate.Anchored = false
This isn’t anything special, but it’s good still. We can index values and set values. This isn’t a real Part instance, so that’s noteworthy despite being kind of boring compared to the rest of what we’re going to do.
We can also easily implement our custom method by sneaking it into the __index
metamethod from our wrap function:
meta.__index = function(s,k)
if k == "TellMeAJoke" then
return function(self)
print(self.Name .. " here, to tell you a joke:")
print("Q: What do you call a fly with no wings?")
print("A: A walk.")
end
end
return wrap(real[k])
end
Now if we call it with our fake Baseplate:
Baseplate:TellMeAjoke()
Baseplate here, to tell you a joke:
Q: What do you call a fly with no wings?
A: A walk!
Easy!
Alright, it gets a bit harder when we start really messing with things. But we should still be able to manage. Take these lines for instance:
Baseplate = Baseplate.Parent.Baseplate
Baseplate:TellMeAJoke()
Baseplate = Baseplate.Parent:FindFirstChild("Baseplate")
Baseplate:TellMeAJoke()
The first attempt to break things is a good one, but the recursion in __index
takes care of it. Baseplate.Parent
returns a wrapped version of Workspace, and indexing that returns another wrapped copy of Baseplate. It passes.
But when we hit Baseplate.Parent:FindFirstChild("Baseplate")
, we get a peculiar error:
Expected ':' not '.' calling member function FindFirstChild
This error is misleading, but it makes sense if you think about it. The :
operator works by automatically passing the object itself as the first argument, then shoving your custom arguments after it. Object:Method(...)
is esentially the same as Object.Method(Object, ...)
, it’s just syntax sugar. The way Roblox tells when you’ve accidentally used a .
where a :
should be is by checking whether the first argument works the way it should. If not, it assumes you’ve used the wrong operator and gives you that helpful error.
In our case, we haven’t used the wrong operator, but there’s still a problem. FindFirstChild is a real method that deals with real objects. Methods like this usually run using Roblox’s C++ back-end, and it’s expecting real data inside the object, something we have no power over. It fails at interacting with our fake Baseplate. In order to solve this problem, we have to build a handler inside our wrapper for functions too. Our wrapped functions will be responsible for feeding the real functions their real values, then giving back wrapped objects for whatever is returned.
There’s one problem though. This implementation requires us to be able to unwrap values as well as wrap them. Since we’re passing wrapped objects and we need to transform them into real objects, we need to be able to take any wrapped object and cleanly get the real object out of it. We can do this by implementing a cache for our wrapped objects. Then we can check the cache anytime we need to cross-reference between wrapped and unwrapped objects.
We’ll create the cache as a table. The keys will be wrapped objects, and they will point to their respective real objects. Because we don’t want this table getting in the way of garbage collection, we’ll use the __mode
metamethod to enable weak keys. This means when there’s no references to the keys beyond the ones in this table, the garbage collector is allowed to collect them.
local wrappercache = setmetatable({}, {__mode = "k"})
Now let’s alter our wrap function. Before doing anything else, it should check if there’s already a wrapped value in the cache. If so, return that. Otherwise, after it’s finished wrapping the object, it should place it in the cache for later.
wrap = function(real)
--first check if the object is already wrapped and available in the cache
for w,r in next,wrappercache do
if r == real then
return w
end
end
--otherwise, handle it ourselves
if type(real) == "userdata" then
local fake = newproxy(true)
local meta = getmetatable(fake)
meta.__index = function(s,k)
if k == "TellMeAJoke" then
return function(self)
print(self.Name .. " here, to tell you a joke:")
print("Q: What do you call a fly with no wings?")
print("A: A walk.")
end
end
return wrap(real[k])
end
meta.__newindex = function(s,k,v)
real[k] = v
end
meta.__tostring = function(s)
return tostring(real)
end
--store the object in the cache, then return
wrappercache[fake] = real
return fake
else
return real
end
end
Now our unwrap function is as simple as checking the cache to see if the real object is there:
unwrap = function(wrapped)
local real = wrappercache[wrapped]
if real == nil then
--theoretically, if it's not in the cache, it's not wrapped, so we'll just return the object without doing anything
return wrapped
end
return real
end
This also inadvertently solves the problem of comparing wrapped objects. Before, if you indexed a wrapped object for the same member twice, you’d get two different fake objects pointing to the same real object. Now it will always return the cached object.
Before implementing the function wrapper, there’s one more thing we should do. We’re dealing with function arguments. We don’t know how many arguments there will be. It would be convenient to handle tables in our wrap and unwrap functions so we don’t have to pass each argument individually. We can do this quickly with some more recursion.
In the wrap function, let’s add a case for tables above the final else block:
elseif type(real) == "table" then
local fake = {}
for k,v in next,real do
fake[k] = wrap(v)
end
return fake
And the same thing in reverse fits snugly into our unwrap function:
unwrap = function(wrapped)
if type(wrapped) == "table" then
local real = {}
for k,v in next,wrapped do
real[k] = unwrap(v)
end
return real
else
local real = wrappercache[wrapped]
if real == nil then
return wrapped
end
return real
end
end
Now we can finally build the function handler into our wrapper. I put it as another elseif block right under the one that handles userdata. It works like this:
elseif type(real) == "function" then
--the goal is to return a fake function that behaves like the real one, then returns wrapped results
local fake = function(...)
--unwrap the arguments so the real method can process the real versions of the objects
local args = unwrap{...}
--call the method with the real arguments, then catch the return values in a table and wrap them
local results = wrap{real(unpack(args))}
--return the wrapped results
return unpack(results)
end
-- let's put fake functions into the cache as well for consistency
wrappercache[fake] = real
return fake
The process of unwrapping the arguments, calling the method, wrapping the return values then returning them can actually all be done on one line, but it looks pretty ugly and hard to read that way. If you’re into that though, I completely understand, and I’d probably do it too if this wasn’t a tutorial.
Moving on, here is the entire script so far:
local wrappercache = setmetatable({}, {__mode = "k"})
wrap = function(real)
for w,r in next,wrappercache do
if r == real then
return w
end
end
if type(real) == "userdata" then
local fake = newproxy(true)
local meta = getmetatable(fake)
meta.__index = function(s,k)
if k == "TellMeAJoke" then
return function(self)
print(self.Name .. " here, to tell you a joke:")
print("Q: What do you call a fly with no wings?")
print("A: A walk.")
end
end
return wrap(real[k])
end
meta.__newindex = function(s,k,v)
real[k] = v
end
meta.__tostring = function(s)
return tostring(real)
end
wrappercache[fake] = real
return fake
elseif type(real) == "function" then
local fake = function(...)
local args = unwrap{...}
local results = wrap{real(unpack(args))}
return unpack(results)
end
wrappercache[fake] = real
return fake
elseif type(real) == "table" then
local fake = {}
for k,v in next,real do
fake[k] = wrap(v)
end
return fake
else
return real
end
end
unwrap = function(wrapped)
if type(wrapped) == "table" then
local real = {}
for k,v in next,wrapped do
real[k] = unwrap(v)
end
return real
else
local real = wrappercache[wrapped]
if real == nil then
return wrapped
end
return real
end
end
local Baseplate = wrap(game.Workspace.Baseplate)
print(Baseplate, Baseplate.Name, Baseplate.ClassName)
Baseplate.Anchored = false
Baseplate:TellMeAJoke()
Baseplate = Baseplate.Parent.Baseplate
Baseplate:TellMeAJoke()
Baseplate = Baseplate.Parent:FindFirstChild("Baseplate")
Baseplate:TellMeAJoke()
--current milestone
NewBaseplate = -Baseplate
NewBaseplate.Parent = game.Workspace
NewBaseplate.Name = "Otherplate"
NewBaseplate:TellMeAJoke()
NewBaseplate.Anchored = true
NewBaseplate.Size = Vector3.new(50,50,50)
Baseplate:Destroy()
Now if we run it, we actually make it past the test…! Baseplate.Parent:FindFirstChild("Baseplate")
returns correctly, and we can even call our custom method on the result still. Try experimenting with more methods if you’d like. It should be compatible with most if not all of them.
We’ve basically reached the point where you can see the idea of how this works and start implementing more handlers and custom features by yourself. But there’s one last thing in our test, and it’s cloning stuff using the unary negative operator. This is an impractical but point-proving example of the kind of ridiculous stuff I’d like to add to the API for myself if I could. I’m sure you can think of better examples.
We can do this by just adding the __unm
operator in the userdata handling block:
meta.__unm = function(s)
return wrap(real:Clone())
end
As you can see, when we have the groundwork laid down already, adding new stuff becomes a cakewalk.
If you run the entire script now, it should run to the end without erroring. Every test should pass. And now you have a lot of freedom to make whatever lazy or useful shortcuts you desire.
(Yes!)
Before I finish this thread, I should point out some more stuff you can work on. Like setting up your whole global environment to only have wrapped variables. Take the following example for a reason why that would be necessary:
local Part = wrap(Instance.new("Part"))
print(typeof(Part))
Roblox’s typeof
function doesn’t support our custom userdatas. This will simply print userdata
even though the expected behavior is that it will print Instance
. But look, here’s a magic trick:
typeof = wrap(typeof)
Now if we try the same thing:
local Part = wrap(Instance.new("Part"))
print(typeof(Part))
It will actually work correctly.
So remember, if you want to make sure everything works in harmony, it all has to be wrappped.
There’s actually a lot more cases than what we covered to handle. Checking class types, wrapping events, making sure errors look pretty, making sure stuff doesn’t get wrapped twice… More work than is necessary for this tutorial. If the above made sense, you should be able to start expanding on this code to write your own wrapper with as much compatibility as you need.
As a side note, this is also how sandboxes in script builder games work. That almost deserves its own thread.
As a side-side note, lots of nasty Roblox cheats use a method similar to this to avoid having to compile code on Roblox’s VM. That almost deserves its own thread too.
If you have any questions, I’m more than happy to answer.