← 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.

local part = Instance.new("Part")
part.Size = Vector3.new(1, 2, 10)
part.Material = Enum.Material.Wood

print(part:GetMass()) -- 6.9999998807907

-- vs:

local function getMass(size, material)
    -- mass = volume * density
    local volume = size.x * size.y * size.z
    local density = PhysicalProperties.new(material).Density
    return volume * density
end

print(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:

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

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:

-- the self argument is still passed, but it's parameter name is no longer hidden and can be changed
function Part.GetMass(self)
    local volume = self.Size.x * self.Size.y * self.Size.z
    local density = PhysicalProperties.new(self.Material).Density
    return volume * density
end

-- still called like a normal method
print(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.

local Vector3 = {}
Vector3.__index = Vector3

-- constructor

function Vector3.new(x, y, z)
    local self = setmetatable({}, Vector3)

    self.x = x or 0
    self.y = y or 0
    self.z = z or 0

    return self
end

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.

local t = {
    cats = 10;
    dogs = 32;
}

local cats = t.cats -- not fired b/c value exists for key
local dogs = t.dogs -- not fired b/c value exists for key
t.turtles = 60 -- not fired b/c we are writing
print(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:

function Vector3:Magnitude()
    local x, y, z = self.x, self.y, self.z
    return math.sqrt(x*x + y*y + z*z)
end

local v = Vector3.new(1, 2, 3)
print(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.

-- Server Script

-- the module script from below
local placementClass = require(game:GetService("ReplicatedStorage"):WaitForChild("Placement"))
local placementObjects = {}

local remotes = game:GetService("ReplicatedStorage"):WaitForChild("Remotes")

-- creates the server twin, stores in a table and returns the CanvasObjects property
function remotes.InitPlacement.OnServerInvoke(player, canvasPart)
    placementObjects[player] = placementClass.new(canvasPart)
    return placementObjects[player].CanvasObjects
end

-- finds the server twin and calls a method on it
-- note: b/c we aren't using the standard method syntax we must manually put in the self argument
remotes.InvokePlacement.OnServerEvent:Connect(function(player, func, ...)
    if (placementObjects[player]) then
        placementClass[func](placementObjects[player], ...)
    end
end)

-- Class (Module Script)

local isServer = game:GetService("RunService"):IsServer()

local Placement = {}
Placement.__index = Placement

function Placement.new(canvasPart)
    local self = setmetatable({}, Placement)

    -- the part we are placing models on
    self.CanvasPart = canvasPart

    -- custom logic depending on if the sevrer or not
    if (isServer) then
        -- create a folder we'll place the objects in
        self.CanvasObjects = Instance.new("Folder")
        self.CanvasObjects.Name = "CanvasObjects"
        self.CanvasObjects.Parent = canvasPart
    else
        -- initiate the twin on the server
        self.CanvasObjects = initPlacement:InvokeServer(canvasPart)
    end

    -- we'll talk about these properties later in the post
    self.Surface = Enum.NormalId.Top
    self.GridUnit = 1

    return self
end

return 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.

-- methods

function Placement:CalcCanvas()
    -- use to find the boundaries and CFrame we need to place object
    -- on a surface
end

function Placement:CalcPlacementCFrame(model, position, rotation)
    -- use to find to find the constrained CFrame of the model 
    -- for placement
end

Let's start filling these out shall we?

function Placement:CalcCanvas()
    local canvasSize = self.CanvasPart.Size

    -- want to create CFrame such that cf.lookVector == self.CanvasPart.CFrame.upVector
    -- do this by using object space and build the CFrame
    local back = Vector3.new(0, -1, 0)
    local top = Vector3.new(0, 0, -1)
    local right = Vector3.new(-1, 0, 0)

    -- convert to world space
    local cf = self.CanvasPart.CFrame * CFrame.fromMatrix(-back*canvasSize/2, right, top, back)
    -- use object space vectors to find the width and height
    local size = Vector2.new((canvasSize * right).magnitude, (canvasSize * top).magnitude)

    return cf, size
end

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.

local test = Placement.new(game.Workspace.ExampleCanvas)
local cf, size = test:CalcCanvas()

print(cf, size)
print(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.

function Placement:CalcPlacementCFrame(model, position, rotation)
    -- use other method to get info about the surface
    local cf, size = self:CalcCanvas()

    -- rotate the size so that we can properly constrain to the surface
    local modelSize = CFrame.fromEulerAnglesYXZ(0, rotation, 0) * model.PrimaryPart.Size
    modelSize = Vector3.new(math.abs(modelSize.x), math.abs(modelSize.y), math.abs(modelSize.z))

    -- get the position relative to the surface's CFrame
    local lpos = cf:pointToObjectSpace(position);
    -- the max bounds the model can be from the surface's center
    local size2 = (size - Vector2.new(modelSize.x, modelSize.z))/2

    -- constrain the position using size2
    local x = math.clamp(lpos.x, -size2.x, size2.x);
    local y = math.clamp(lpos.y, -size2.y, size2.y);

    -- create and return the CFrame
    return cf * CFrame.new(x, y, -modelSize.y/2) * CFrame.Angles(-math.pi/2, rotation, 0)
end

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

local canvas = game.Workspace.ExampleCanvas
local furniture = game.ReplicatedStorage.Furniture

-- create an object with the constructor
local placement = Placement.new(canvas)

local mouse = game.Players.LocalPlayer:GetMouse()
mouse.TargetFilter = placement.CanvasObjects

local tableModel = furniture.Table:Clone()
tableModel.Parent = mouse.TargetFilter

local rotation = 0

local function onRotate(actionName, userInputState, input)
    if (userInputState == Enum.UserInputState.Begin) then
        rotation = rotation + math.pi/2
    end
end

game:GetService("ContextActionService"):BindAction("rotate", onRotate, false, Enum.KeyCode.R)

game:GetService("RunService").RenderStepped:Connect(function(dt)
    local cf = placement:CalcPlacementCFrame(tableModel, mouse.Hit.p, rotation)
    tableModel:SetPrimaryPartCFrame(cf)
end)

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.

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

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.

-- new method

function Placement:Place(model, cf)
    if (isServer) then
        local clone = model:Clone()
        clone:SetPrimaryPartCFrame(cf)
        clone.Parent = self.CanvasObjects
    end

    if (not isServer) then
        invokePlacement:FireServer("Place", model, cf, isColliding)
    end
end

-- put it into action

local function onPlace(actionName, userInputState, input)
    if (userInputState == Enum.UserInputState.Begin) then
        local cf = placement:CalcPlacementCFrame(tableModel, mouse.Hit.p, rotation)
        placement:Place(furniture[tableModel.Name], cf)
    end
end

game: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.

function Placement:isColliding(model)
    local isColliding = false

    -- must have a touch interest for the :GetTouchingParts() method to work
    local touch = model.PrimaryPart.Touched:Connect(function() end)
    local touching = model.PrimaryPart:GetTouchingParts()

    -- if intersecting with something that isn't part of the model then can't place
    for i = 1, #touching do
        if (not touching[i]:IsDescendantOf(model)) then
            isColliding = true
            break
        end
    end

    -- cleanup and return
    touch:Disconnect()
    return isColliding
end

function Placement:Place(model, cf, isColliding)
    if (not isColliding and isServer) then
        local clone = model:Clone()
        clone:SetPrimaryPartCFrame(cf)
        clone.Parent = self.CanvasObjects
    end

    if (not isServer) then
        invokePlacement:FireServer("Place", model, cf, isColliding)
    end
end

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.

function Placement:CalcCanvas()
    local canvasSize = self.CanvasPart.Size

    local up = Vector3.new(0, 1, 0)
    local back = -Vector3.FromNormalId(self.Surface)

    -- if we are using the top or bottom then we treat right as up
    local dot = back:Dot(Vector3.new(0, 1, 0))
    local axis = (math.abs(dot) == 1) and Vector3.new(-dot, 0, 0) or up

    -- rotate around the axis by 90 degrees to get right vector
    local right = CFrame.fromAxisAngle(axis, math.pi/2) * back
    -- use the cross product to find the final vector
    local top = back:Cross(right).unit

    -- convert to world space
    local cf = self.CanvasPart.CFrame * CFrame.fromMatrix(-back*canvasSize/2, right, top, back)
    -- use object space vectors to find the width and height
    local size = Vector2.new((canvasSize * right).magnitude, (canvasSize * top).magnitude)

    return cf, size
end

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.

-- Server Script

local datastore = game:GetService("DataStoreService"):GetDataStore("PlacementSystem")

function remotes.DSPlacement.OnServerInvoke(player, saving, useData)
    local key = "player_"..player.UserId

    local success, result = pcall(function()
        if (saving and placementObjects[player]) then
            if (useData) then
                -- save the data
                -- :Serialize() is a method we'll talk about in a second
                datastore:SetAsync(key, placementObjects[player]:Serialize())
            else
                -- clear the data
                datastore:SetAsync(key, {})
            end
        elseif (not saving) then
            -- load the data
            return datastore:GetAsync(key)
        end
    end)

    if (success) then
        -- return true if we had success or the loaded data
        return saving or result
    else
        -- show us the error if something went wrong
        warn(result)
    end
end

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.

function Placement:Serialize()
    local serial = {}

    local cfi = self.CanvasPart.CFrame:inverse()
    local children = self.CanvasObjects:GetChildren()

    -- key = object space cframe string
    -- value = object name
    for i = 1, #children do
        local objectSpaceCF = cfi * children[i].PrimaryPart.CFrame
        serial[tostring(objectSpaceCF)] = children[i].Name 
    end

    return serial
end

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.

function Placement.fromSerialization(canvasPart, data)
    local self = Placement.new(canvasPart)
    local canvasCF = canvasPart.CFrame

    -- if data is nil or empty then this constructor is the same as .new()
    data = data or {}

    for cf, name in pairs(data) do
        -- find the furniture model with the same name
        local model = furniture:FindFirstChild(name)
        if (model) then
            -- convert the string CFrame to a real CFrame
            local components = {}
            for num in string.gmatch(cf, "[^%s,]+") do
                components[#components+1] = tonumber(num)
            end

            -- place the object
            self:Place(model, canvasCF * CFrame.new(unpack(components)), false)
        end
    end

    return self
end

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
incapaxian says: November 26, 2018
second that's nice
EXpodo1234ALT 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!
0xABCDEF says: December 11, 2018
ninth that's nice
upVector says: December 12, 2018
Very useful!