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.

criticalLuaudit check: unchecked-placement

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, Vector3 or grid coordinates for placing, moving or resizing something, and follow the value to PivotTo, CFrame, Position or Size.
  • Check the value is type-checked with typeof and 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 GetExtentsSize or GetBoundingBox on 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 CFrame or Vector3, 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 CFrame on 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.