Scripting Helpers is winding down operations and is now read-only. More info→
← Blog Home

Creating A Furniture Placement System

One of the most common requests I get for blog post topics are for a decoration placement system that saves. I’ve shied away from this topic in the past as there is no singular “correct” way to do it. That being said, I think it's a good exercise for all game devs to go through and will potentially place a role in a future blog post I plan to write.

In addition to change things up, I’ll also take the time to explain and use Object Oriented Programming (OOP) as it’s not only my own preferred style, but also something Roblox uses for their core scripts.


Alright, let's get into it!

Object Oriented Programming

OOP is all about writing classes which in this context is a very fancy synonym for the word “blueprint”. We can then use these blueprints to create objects that have certain properties and methods.

If some of these words sound familiar to you, well, that’s because they are. The objects that you interact with such as Parts, Humanoids, Motor6Ds, and so forth are by design of the OOP paradigm. These objects have properties and methods that when combined define how they interact with our game.

Properties are used to define “characteristics” of an object. For example, a Part has a property called Size which as its name suggests defines how large or small the object is. In turn these properties often play a role in the behaviour and actions associated with said object which is defined by methods. For instance, the method :GetMass() returns the mass of the part which among other things varies with size. Thus, we can see an example of a clear connection here between methods and properties.

Now that we have some of the terminology down and an example I’d like to further discuss the distinction between classes and objects. A class defines the properties and methods that an object will have. For example, we know all parts are going to have a position property, so we define it in our class. The actual value of the position property will vary between individual objects, but the overarching concept of a part we know will have a property called Position with a Vector3 as its value. In a similar sense when writing the methods of our class we may not know the literal values for each property, but since we know that those values will exist, we can treat them almost like function parameters.

The difference between OOP and a more functional approach for the same task can be seen in the code sample below.

01local part = Instance.new("Part")
02part.Size = Vector3.new(1, 2, 10)
03part.Material = Enum.Material.Wood
04 
05print(part:GetMass()) -- 6.9999998807907
06 
07-- vs:
08 
09local function getMass(size, material)
10    -- mass = volume * density
11    local volume = size.x * size.y * size.z
12    local density = PhysicalProperties.new(material).Density
13    return volume * density
14end
15 
16print(getMass(part.Size, part.Material)) -- 6.9999998807907

Ignoring that fact that the method is built-in and thus didn’t have to be defined, the only difference was that in the functional approach we had to plug in the part’s properties as arguments manually. In the method’s case we didn’t have to plug in any arguments because Lua knew we were calling a method on a specific object and thus could grab the properties needed directly from it. A method is just the name we give to functions that are applied to a specific object. An example of what this might look like would be this:

1function Part:GetMass()
2    local volume = self.Size.x * self.Size.y * self.Size.z
3    local density = PhysicalProperties.new(self.Material).Density
4    return volume * density
5end

You might note that the above code is referencing something called self and understandably this seems like it’s coming out of thin air. In Lua, when you call a method the first argument passed is ALWAYS the object the method was called on. When defining a method with the syntax form function Class:MyMethod(param1, param2, …) the parameter that will represent the object is forcibly given the name self and shouldn’t be defined in the brackets like any other extra parameters. So, if I ran somePart:GetMass() then the argument that would replace self would be somePart.

As a side note, either due to personal preference or a familiarity with other languages that use another keyword other than self such as this, some programmers may wonder if there’s a way to use a different parameter name. This is possible, and the equivalent code would be the following:

1-- the self argument is still passed, but it's parameter name is no longer hidden and can be changed
2function Part.GetMass(self)
3    local volume = self.Size.x * self.Size.y * self.Size.z
4    local density = PhysicalProperties.new(self.Material).Density
5    return volume * density
6end
7 
8-- still called like a normal method
9print(somePart:GetMass())

It would be my personal recommendation however that you do not do this as it can be confusing to others from a readability perspective.

Alright, so how do we write a class? The last piece of the puzzle is something called a constructor. A constructor creates an object from the class and returns it back to us with a set of filled in properties. A very common constructor that I think (?) all of the built-in classes have is .new() but other examples might be Vector3.FromNormalId or CFrame.Angles. A class can have multiple constructors and they can be named just about anything. Sometimes when we write these constructors, they have parameters that help us fill in properties and other times they don’t. It’s completely up to you as the programmer and dependent on what the class if for.

Let’s look at how the staff at Roblox might write a constructor in Lua and we’ll break down the parts from there. Here’s an example of how one might copy the Vector3 class constructor.

01local Vector3 = {}
02Vector3.__index = Vector3
03 
04-- constructor
05 
06function Vector3.new(x, y, z)
07    local self = setmetatable({}, Vector3)
08 
09    self.x = x or 0
10    self.y = y or 0
11    self.z = z or 0
12 
13    return self
14end

To some of you this might already make perfect sense and to some of you it may not. The key difference between those who understand and those who do not should be familiarity with metatables. This is a big topic in of itself, but luckily, we only really need to understand one aspect of the __index metamethod to understand this code.

The best “dumbed down” explanation I’ve heard of meta-tables is “events, but for tables” and this is particularly applicable to the __index metamethod. The __index metamethod is “fired” when an non-existing key in a the table is indexed meaning read, not written.

1local t = {
2    cats = 10;
3    dogs = 32;
4}
5 
6local cats = t.cats -- not fired b/c value exists for key
7local dogs = t.dogs -- not fired b/c value exists for key
8t.turtles = 60 -- not fired b/c we are writing
9print(t.hamsters) -- fired b/c value does not exist for key

Now typically metamethods will "fire" a function and the __index metamethod can also work in this way. However, if instead of setting a function to the __index metamethod you set another table then when the __index metamethod is “fired” it treats the process as such:

  • Table was indexed with key => Does the key correspond with a nil value in the table?
    • Yes => Does the key correspond with a non-nil value in the table in the __index metamethod?
      • Yes => Return that value
      • No => Return nil

This is quite useful to us at it allows us to set default values to keys and not have to constantly redefine and repeat ourselves when making copies. In the case of the above code we use this such that self, the table we return from our constructor, will have access to all the constructors and methods we attach to the Vector3 table.

Say we add the following method to the above code then create an object and run the method on it:

1function Vector3:Magnitude()
2    local x, y, z = self.x, self.y, self.z
3    return math.sqrt(x*x + y*y + z*z)
4end
5 
6local v = Vector3.new(1, 2, 3)
7print(v:Magnitude())

The process is treated as such:

  • v was indexed with Magnitude key => Does the key correspond to a nil value in v?
    • Yes => Does the key correspond with a non-nil value in Vector3?
      • Yes => Return that value (the magnitude method)

Thus the :Magnitude() method is called on v which has actual values for properties x, y, and z and as such we get the corresponding result.

There’s a lot more to be said about OOP and what I have explained has barely scratched the surface. Some other languages force you to use OOP and have a much richer set of features compared to Lua. If you want to further explore OOP in Lua I recommend you read the following post on the devforums.

All that being said, the question I still have not answered is “Why use OOP?”. My answer: I personally enjoy it as it forces me to organize my code in a modular and reusable way that can be combined together for a variety of complex tasks. That being said there's positives and negatives for everything so use what works for you.

Grid placement

Finally we can get into the purpose of this blog post! Before we even start about specifics let's layout a few things we want our placement system to do.

  • Constrain objects for placement on a flat surface
  • Filtering enabled friendly
  • Save any placements we make so they are there when we rejoin the game
  • Ability to clear any objects we have placed

In this section we'll mainly be focusing on the flat surface constraining part, but we'll also want to take into account filtering enabled for some of this. As such our first step is going to be talking about that.

Now I don't know about you guys, but personally when I write FE friendly stuff I don't like to repeat myself. Unfortunately that's a rabbit hole that we can easily fall down. So the way I approach FE with OOP is to use the RunService:IsServer() method. This tells me if my code is being run on the server or on the client. If I bake that into my own class then when I run things I'll be able to have both logic for client and server specific things. The only catch here is that I actually need two identical objects created by the class. One on the server, and another on the client. I'll henceforth refer to these as twins.

For the sake of this post here's how I might setup the server handing and the class constructor.

01-- Server Script
02 
03-- the module script from below
04local placementClass = require(game:GetService("ReplicatedStorage"):WaitForChild("Placement"))
05local placementObjects = {}
06 
07local remotes = game:GetService("ReplicatedStorage"):WaitForChild("Remotes")
08 
09-- creates the server twin, stores in a table and returns the CanvasObjects property
10function remotes.InitPlacement.OnServerInvoke(player, canvasPart)
11    placementObjects[player] = placementClass.new(canvasPart)
12    return placementObjects[player].CanvasObjects
13end
14 
15-- finds the server twin and calls a method on it
16-- note: b/c we aren't using the standard method syntax we must manually put in the self argument
17remotes.InvokePlacement.OnServerEvent:Connect(function(player, func, ...)
18    if (placementObjects[player]) then
19        placementClass[func](placementObjects[player], ...)
20    end
21end)
22 
23-- Class (Module Script)
24 
25local isServer = game:GetService("RunService"):IsServer()
26 
27local Placement = {}
28Placement.__index = Placement
29 
30function Placement.new(canvasPart)
31    local self = setmetatable({}, Placement)
32 
33    -- the part we are placing models on
34    self.CanvasPart = canvasPart
35 
36    -- custom logic depending on if the sevrer or not
37    if (isServer) then
38        -- create a folder we'll place the objects in
39        self.CanvasObjects = Instance.new("Folder")
40        self.CanvasObjects.Name = "CanvasObjects"
41        self.CanvasObjects.Parent = canvasPart
42    else
43        -- initiate the twin on the server
44        self.CanvasObjects = initPlacement:InvokeServer(canvasPart)
45    end
46 
47    -- we'll talk about these properties later in the post
48    self.Surface = Enum.NormalId.Top
49    self.GridUnit = 1
50 
51    return self
52end
53 
54return Placement

Moving onto methods, we'll probably want at least two (we can always add more later). The first method, :CalcCanvas() will tell us the surface's CFrame (rotation and center) and size (width and height). The :CalcPlacementCFrame() method will be used to find the model parameter's constrained CFrame that is closest to the position parameter. The rotation pameter will allow us to adjust for when we wish to rotate our model by 90 degree increments.

01-- methods
02 
03function Placement:CalcCanvas()
04    -- use to find the boundaries and CFrame we need to place object
05    -- on a surface
06end
07 
08function Placement:CalcPlacementCFrame(model, position, rotation)
09    -- use to find to find the constrained CFrame of the model
10    -- for placement
11end

Let's start filling these out shall we?

01function Placement:CalcCanvas()
02    local canvasSize = self.CanvasPart.Size
03 
04    -- want to create CFrame such that cf.lookVector == self.CanvasPart.CFrame.upVector
05    -- do this by using object space and build the CFrame
06    local back = Vector3.new(0, -1, 0)
07    local top = Vector3.new(0, 0, -1)
08    local right = Vector3.new(-1, 0, 0)
09 
10    -- convert to world space
11    local cf = self.CanvasPart.CFrame * CFrame.fromMatrix(-back*canvasSize/2, right, top, back)
12    -- use object space vectors to find the width and height
13    local size = Vector2.new((canvasSize * right).magnitude, (canvasSize * top).magnitude)
14 
15    return cf, size
16end

Sure enough, if we try this out and draw out the CFrame in some way we'll see that it matches the rotation, it's placed in the center of the surface, and the lookVector is equivalent to the top-surface's normal.

1local test = Placement.new(game.Workspace.ExampleCanvas)
2local cf, size = test:CalcCanvas()
3 
4print(cf, size)
5print(cf.lookVector == game.Workspace.ExampleCanvas.CFrame.upVector) -- true

img1

Now before moving onto the :CalcPlacementCFrame() method let's discuss a few assumptions we can make about the models we place. The biggest of these assumptions is that they will have a primary part which represents their bounding box. This part will likely be completely transparent and non-collidable, but those choices are up to you as the developer.

img2

Now that we have that out of the way let's fill in the method.

01function Placement:CalcPlacementCFrame(model, position, rotation)
02    -- use other method to get info about the surface
03    local cf, size = self:CalcCanvas()
04 
05    -- rotate the size so that we can properly constrain to the surface
06    local modelSize = CFrame.fromEulerAnglesYXZ(0, rotation, 0) * model.PrimaryPart.Size
07    modelSize = Vector3.new(math.abs(modelSize.x), math.abs(modelSize.y), math.abs(modelSize.z))
08 
09    -- get the position relative to the surface's CFrame
10    local lpos = cf:pointToObjectSpace(position);
11    -- the max bounds the model can be from the surface's center
12    local size2 = (size - Vector2.new(modelSize.x, modelSize.z))/2
13 
14    -- constrain the position using size2
15    local x = math.clamp(lpos.x, -size2.x, size2.x);
16    local y = math.clamp(lpos.y, -size2.y, size2.y);
17 
18    -- create and return the CFrame
19    return cf * CFrame.new(x, y, -modelSize.y/2) * CFrame.Angles(-math.pi/2, rotation, 0)
20end

Now we have a basic working surface placement method. Let's try testing it out.

01local canvas = game.Workspace.ExampleCanvas
02local furniture = game.ReplicatedStorage.Furniture
03 
04-- create an object with the constructor
05local placement = Placement.new(canvas)
06 
07local mouse = game.Players.LocalPlayer:GetMouse()
08mouse.TargetFilter = placement.CanvasObjects
09 
10local tableModel = furniture.Table:Clone()
11tableModel.Parent = mouse.TargetFilter
12 
13local rotation = 0
14 
15local function onRotate(actionName, userInputState, input)
16    if (userInputState == Enum.UserInputState.Begin) then
17        rotation = rotation + math.pi/2
18    end
19end
20 
21game:GetService("ContextActionService"):BindAction("rotate", onRotate, false, Enum.KeyCode.R)
22 
23game:GetService("RunService").RenderStepped:Connect(function(dt)
24    local cf = placement:CalcPlacementCFrame(tableModel, mouse.Hit.p, rotation)
25    tableModel:SetPrimaryPartCFrame(cf)
26end)

gif1

Everything looks good, but sometimes we might want to be locked to a grid. To do this we return to our :CalcPlacementCFrame() method and round the x and y variables to the nearest grid value.

1function Placement:CalcPlacementCFrame(model, position, rotation)
2    -- one of the properties I didn't explain earlier
3    local g = self.GridUnit
4    if (g > 0) then
5        x = math.sign(x)*((math.abs(x) - math.abs(x) % g) + (size2.x % g))
6        y = math.sign(y)*((math.abs(y) - math.abs(y) % g) + (size2.y % g))
7    end
8end

Now say we set GridUnit to 2 then our placement system will be locked to a 2x2 grid!

gif2

Placing the object

Right, so in my opinion getting objects to snap and constrain to a surface is the hardest part, but there's still a few other things we have to do before our placement system is complete.

The main thing we have still yet to cover is finalizing the object's position and then locking it there. This is quite easy in our current form as all we have all the information, but we need to make sure we do the placement on the server. We'll do this with a new method called :Place() which will fire a remote event to place the objects.

01-- new method
02 
03function Placement:Place(model, cf)
04    if (isServer) then
05        local clone = model:Clone()
06        clone:SetPrimaryPartCFrame(cf)
07        clone.Parent = self.CanvasObjects
08    end
09 
10    if (not isServer) then
11        invokePlacement:FireServer("Place", model, cf, isColliding)
12    end
13end
14 
15-- put it into action
16 
17local function onPlace(actionName, userInputState, input)
18    if (userInputState == Enum.UserInputState.Begin) then
19        local cf = placement:CalcPlacementCFrame(tableModel, mouse.Hit.p, rotation)
20        placement:Place(furniture[tableModel.Name], cf)
21    end
22end
23 
24game:GetService("ContextActionService"):BindAction("place", onPlace, false, Enum.UserInputType.MouseButton1)

gif3

You'll hopefully notice from the above gif that everything looks pretty good with the exception that we can currently overlap objects. The way we will deal with this is by creating a method that checks if we can validly place the object down and then passing that into our :Place() method.

01function Placement:isColliding(model)
02    local isColliding = false
03 
04    -- must have a touch interest for the :GetTouchingParts() method to work
05    local touch = model.PrimaryPart.Touched:Connect(function() end)
06    local touching = model.PrimaryPart:GetTouchingParts()
07 
08    -- if intersecting with something that isn't part of the model then can't place
09    for i = 1, #touching do
10        if (not touching[i]:IsDescendantOf(model)) then
11            isColliding = true
12            break
13        end
14    end
15 
16    -- cleanup and return
17    touch:Disconnect()
18    return isColliding
19end
20 
21function Placement:Place(model, cf, isColliding)
22    if (not isColliding and isServer) then
23        local clone = model:Clone()
24        clone:SetPrimaryPartCFrame(cf)
25        clone.Parent = self.CanvasObjects
26    end
27 
28    if (not isServer) then
29        invokePlacement:FireServer("Place", model, cf, isColliding)
30    end
31end

Awesome we don't have to worry about that overlap problem anymore!

gif4

Advanced: Using surfaces other than the top

This is a minor addition and can be completely skipped, but for those who are interested about how we might use the other surfaces of a cube aside from the top this section is for you. Just keep in mind that all this can be just as easily achieved if you rotate the canvas part such that it's top surface is facing where you want.

To do this we return to our :CalcCanvas() method. Before we used fixed object space vectors to represent the top surface CFrame. If we wanted to have this work for other surface we go through a similar process, but we need to do some actual calculations. We'll now use the property Surface which will be a NormalId enum to represent the normal of the surface we want to calculate the CFrame for.

01function Placement:CalcCanvas()
02    local canvasSize = self.CanvasPart.Size
03 
04    local up = Vector3.new(0, 1, 0)
05    local back = -Vector3.FromNormalId(self.Surface)
06 
07    -- if we are using the top or bottom then we treat right as up
08    local dot = back:Dot(Vector3.new(0, 1, 0))
09    local axis = (math.abs(dot) == 1) and Vector3.new(-dot, 0, 0) or up
10 
11    -- rotate around the axis by 90 degrees to get right vector
12    local right = CFrame.fromAxisAngle(axis, math.pi/2) * back
13    -- use the cross product to find the final vector
14    local top = back:Cross(right).unit
15 
16    -- convert to world space
17    local cf = self.CanvasPart.CFrame * CFrame.fromMatrix(-back*canvasSize/2, right, top, back)
18    -- use object space vectors to find the width and height
19    local size = Vector2.new((canvasSize * right).magnitude, (canvasSize * top).magnitude)
20 
21    return cf, size
22end

Here's that in action using the left surface instead of the top.

gif5

Using datastore to save placements

You may find that depending on your game you might want the player to be able to leave and come back to their canvas part with all the stuff they placed on it. To do this we are going to take advantage of datastores.

To start off we need another remote function since the datastore only works on the server. We shouldn't need to pass any data to the server aside from if we should save, clear, or load the data. This is because we already have a twin on the server that has the exact same information.

01-- Server Script
02 
03local datastore = game:GetService("DataStoreService"):GetDataStore("PlacementSystem")
04 
05function remotes.DSPlacement.OnServerInvoke(player, saving, useData)
06    local key = "player_"..player.UserId
07 
08    local success, result = pcall(function()
09        if (saving and placementObjects[player]) then
10            if (useData) then
11                -- save the data
12                -- :Serialize() is a method we'll talk about in a second
13                datastore:SetAsync(key, placementObjects[player]:Serialize())
14            else
15                -- clear the data
16                datastore:SetAsync(key, {})
17            end
18        elseif (not saving) then
19            -- load the data
20            return datastore:GetAsync(key)
21        end
22    end)
23 
24    if (success) then
25        -- return true if we had success or the loaded data
26        return saving or result
27    else
28        -- show us the error if something went wrong
29        warn(result)
30    end
31end

Next, as alluded to in the code above we are going to define a new method called :Serialize(). This method will convert all the objects we have currently placed into a format that can be stored in a datastore. Since I just want to give a simple example we'll do this by creating a dictionary where an object space CFrame is the key and the furniture item's name is the value.

01function Placement:Serialize()
02    local serial = {}
03 
04    local cfi = self.CanvasPart.CFrame:inverse()
05    local children = self.CanvasObjects:GetChildren()
06 
07    -- key = object space cframe string
08    -- value = object name
09    for i = 1, #children do
10        local objectSpaceCF = cfi * children[i].PrimaryPart.CFrame
11        serial[tostring(objectSpaceCF)] = children[i].Name
12    end
13 
14    return serial
15end

Now whenever we are ready to save we can store the return of the :Serialize() method to the server and retrieve it later.

The next question we have to answer is how to use that information to put everything back where it was. To do this we will create another constructor. It will take not only a canvas part as an argument, but also retrieved serialized data.

01function Placement.fromSerialization(canvasPart, data)
02    local self = Placement.new(canvasPart)
03    local canvasCF = canvasPart.CFrame
04 
05    -- if data is nil or empty then this constructor is the same as .new()
06    data = data or {}
07 
08    for cf, name in pairs(data) do
09        -- find the furniture model with the same name
10        local model = furniture:FindFirstChild(name)
11        if (model) then
12            -- convert the string CFrame to a real CFrame
13            local components = {}
14            for num in string.gmatch(cf, "[^%s,]+") do
15                components[#components+1] = tonumber(num)
16            end
17 
18            -- place the object
19            self:Place(model, canvasCF * CFrame.new(unpack(components)), false)
20        end
21    end
22 
23    return self
24end

Sure enough if we make sure to save and load our data we get the following result:

gif6

Conclusion

That was quite the exercise and there was a lot to learn, but hopefully you are walking away from this with more info in your noggin than before. If you want to see the place in action then check out this link.

That's all for now folks, hopefully I'll have another post soon!

Posted in Scripting Tips

Commentary

Leave a Comment

fr2013 says: November 26, 2018
first that's nice
User#24403 says: November 26, 2018
second that's nice
User#23365 says: November 26, 2018
third that's nice
LoganboyInCO says: November 26, 2018
im first cause i can
saSlol2436 says: November 26, 2018
This was such an awesome tutorial!
greatneil80 says: November 26, 2018
Love it, thank you so much for creating what I wanted, It will not only be helpful for the communities growth in programmers, but it will also inspire others to code like you!!! ^_^
THELASTPlCKLE says: November 27, 2018
Very useful!
zblox164 says: December 1, 2018
Can't believe I had not seen this before! It make my placements system look like a noob wrote them!
ee0w says: December 11, 2018
ninth that's nice
nilVector says: December 12, 2018
Very useful!
TypicallyPacific says: December 25, 2018
eleventh that's nice
GoldAngelInDisguise says: December 26, 2018
cool
SavageDarry says: December 27, 2018
I've made a placement system already I just dont know how to make it that if it belongs to someone else they can't use it nor place stuff on it.
DbExpress says: January 1, 2019
fourth that's nice
WideSteal321 says: January 6, 2019
a number that's nice
OptimisticSide says: January 8, 2019
fourth that's nice
Asentis says: January 8, 2019
Very cool
VoidKeyword says: January 15, 2019
Great!
ScriptingNubs says: February 4, 2019
Noice!
dw_jw says: February 14, 2019
Thank you maybe I can fulfill my dreams of making awesome games?
Igoralexeymarengobr says: February 19, 2019
math.round() isntead of values like 8.8262265995 you get a 9 Ha jk i would like to know something like thatin roblox ;-;
zaniac10 says: March 1, 2019
How would one go about un-restricting it from the canvas and allowing it to be placed anywhere where your mouse is, but it still includes the GridUnit snapping?
ArtyomAL3X says: March 9, 2019
fiveseventh thats nice
VVickedDev says: April 9, 2019
Now thats some really good physics+code skills. Thats a lot of damage
starmaq says: April 17, 2019
Goodjob Ego, explained perfectly as usual. Thanks a lot for covering such useful and always used things that aren't found anywhere else.
Seisimotic says: June 5, 2019
epic
Babyseal1015 says: July 2, 2019
Thank you! Awesome tutorial.
GamingWithFlight says: August 18, 2019
third that's nice
lon233bcc says: August 20, 2019
can i do this for a building gui
zed_103 says: October 9, 2019
where do i put all of the scripts explain one by one
LuukOriginal says: October 13, 2019
Hi thnx for the help, I only have 1 question, how to make the system enable and disable like a build button and a back button?
robloxsario says: December 1, 2019
Thanks for this tutorial. And you will be an inspiration for future developers like me!
INOOBE_YT says: February 9, 2020
i love egomoose!!!
marsdonh says: March 30, 2020
very cool thanks
luksloks says: April 13, 2020
Parabens!
IAmNotTheReal_MePipe says: April 17, 2020
This is awesome
i_Rook says: May 9, 2020
Thanks so much! Now Im wondering how to get an Item from a shop/inventory, then be able to place onto a grid. thanks!
cucucu0001 says: May 22, 2020
the scripts are giving me headaches :'(
ilostmyaccount678 says: May 27, 2020
I can't thank you enough for this
GAM3RBOY2008 says: May 30, 2020
woah. a very long script, but very well made script. of course its not perfect, but it is very good for a script ONLY that long !
Cynical_Innovation says: August 14, 2020
2020 moment
Sriram2008 says: August 30, 2020
love dis
deadwalker601 says: August 31, 2020
How do we do this with multiple parts? I have to placing done, but I cant figure out how to save or load. Thanks in advance!
Cynical_Innovation says: September 18, 2020
2020 moment
NillaTheCat12345 says: October 16, 2020
<3
goatmanthe90th says: October 29, 2020
Good job. How can I make this be able to be toggled via textbutton screen gui?
CoolBlueJay000 says: January 31, 2021
second thats nice
Sop8x says: March 26, 2021
good read
steve_xpp says: July 20, 2021
How can I change the (switch.KeyCode.E) so that the chair appears when I press a button
yaroslav664 says: January 2, 2023
i have 2 questions, can it works with inventory system? And, can it works in 4 lands?