How to validate building placement on the server
A CFrame from the client can put furniture on other players' plots or across the map. How to check the whole item fits inside the player's own plot.
What it looks like
A building, decorating or tycoon system lets the client say where to put something. The server checks the player owns the item, then places it at the CFrame or position the client sent without checking it lands inside that player's own plot.
Luau
remotes.PlaceItem.OnServerEvent:Connect(function(player, itemName, cframe) local model = items[itemName]:Clone() model:PivotTo(cframe) model.Parent = plotOf(player).Furniture end)
How to detect it
- Find remotes that take a
CFrame,Vector3or grid coordinates for placing, moving or resizing something, and follow the value toPivotTo,CFrame,PositionorSize. - Check the value is type-checked with
typeofand that every component is a finite number. NaN and infinity fail ordinary range checks when the check is written as a rejection. - Look for a bounds check against the player's own plot, done on the server. The usual form converts the position into the plot's space with
plot.CFrame:ToObjectSpace(cframe)and compares it with half the plot's size. - The check must cover the whole item, not just its pivot. A large item pivoted at the edge of a plot sticks out into whatever is next to it. Use the item's size, from
GetExtentsSizeorGetBoundingBoxon the server, or a size kept in a server table. - Check the height too, and any rotation limits the game relies on. A check on X and Z alone lets items float far above or sink below the plot.
- A plot found from the client's arguments, or any plot rather than the player's own, isn't a bounds check. That part is also Instance arguments used without an ownership check; report it once, here.
What an exploiter could do
They place items anywhere: on other players' plots, across paths and doors, around the spawn, over shops or inside other players' bases. Walls and barriers placed this way can trap players, block the map or cover what others built. Items that pay out, such as droppers and conveyors, can be placed where they earn more than intended.
How to fix it
- Accept a position only as a
CFrameorVector3, and reject it unless every component is finite. - Convert it into the player's own plot space on the server, snap it to the grid there, and check the item's whole extent fits inside the plot, height included.
- Allow only the rotations the game uses, such as quarter turns around the vertical axis, by building the final
CFrameon the server from an angle step. - Cap how many items each plot can hold.
Vulnerable example
Vulnerable
local ReplicatedStorage = game:GetService("ReplicatedStorage") local ServerStorage = game:GetService("ServerStorage") local Plots = require(script.Parent.Plots) local PlayerData = require(script.Parent.PlayerData) ReplicatedStorage.Remotes.PlaceItem.OnServerEvent:Connect(function(player: Player, itemName: unknown, cframe: unknown) if typeof(itemName) ~= "string" or typeof(cframe) ~= "CFrame" then return end local profile = PlayerData.get(player) local plot = Plots.get(player) local template = ServerStorage.Furniture:FindFirstChild(itemName) local furniture = plot and plot:FindFirstChild("Furniture") if not profile or not furniture or not template or not template:IsA("Model") then return end local index = table.find(profile.Inventory, itemName) if not index then return end table.remove(profile.Inventory, index) local model = template:Clone() model:PivotTo(cframe) model.Parent = furniture end)
Fixed example
Fixed
local ReplicatedStorage = game:GetService("ReplicatedStorage") local ServerStorage = game:GetService("ServerStorage") local Plots = require(script.Parent.Plots) local PlayerData = require(script.Parent.PlayerData) local grid = 2 local maxItems = 150 local function finite(value: number): boolean return value == value and math.abs(value) < math.huge end ReplicatedStorage.Remotes.PlaceItem.OnServerEvent:Connect(function(player: Player, itemName: unknown, cell: unknown, turns: unknown) if typeof(itemName) ~= "string" or typeof(cell) ~= "Vector3" or typeof(turns) ~= "number" then return end if not (finite(cell.X) and finite(cell.Z)) or turns % 1 ~= 0 or turns < 0 or turns > 3 then return end local profile = PlayerData.get(player) local plot = Plots.get(player) local base = plot and plot:FindFirstChild("Base") local furniture = plot and plot:FindFirstChild("Furniture") local template = ServerStorage.Furniture:FindFirstChild(itemName) if not profile or not base or not base:IsA("BasePart") or not furniture or not template or not template:IsA("Model") then return end if #furniture:GetChildren() >= maxItems or not table.find(profile.Inventory, itemName) then return end local size = template:GetExtentsSize() local width = if turns % 2 == 0 then size.X else size.Z local depth = if turns % 2 == 0 then size.Z else size.X local x = math.round(cell.X / grid) * grid local z = math.round(cell.Z / grid) * grid local half = base.Size / 2 if math.abs(x) + width / 2 > half.X or math.abs(z) + depth / 2 > half.Z then return end table.remove(profile.Inventory, table.find(profile.Inventory, itemName) :: number) local model = template:Clone() local floor = base.CFrame * CFrame.new(x, half.Y + size.Y / 2, z) * CFrame.Angles(0, turns * math.pi / 2, 0) model:PivotTo(floor) model.Parent = furniture end)
Common false positives
- Placement on fixed slots, where the client picks a slot id and the server looks up its position, has no free position to check. Judge the slot id with Choice not checked against what the player has unlocked.
- Parts the client places only on its own screen, such as a ghost preview, are fine. Only what the server creates matters.
- Moves limited to rotating an item in place, like quarter turns built on the server, don't move it out of the plot.