Building System doesn't snap to the right place

(This is a duplicate post, the last one didn’t get any attention)
I’m trying to make a building system, but I seem to have encountered a pretty confusing issue.

as you can see, in the video, the block doesn’t snap to the right y axis, making it either floating or underground
Here’s the code snippet (it’s not the most optimized code but it works ig)

local STUDS_UNIT = 4
local ANGLE_INCR = 90

local player = game.Players.LocalPlayer
local UserInputService = game:GetService("UserInputService")
local CollectionService = game:GetService("CollectionService")
local RunService = game:GetService("RunService")

local PlacementEvent = game.ReplicatedStorage.Event.PlacementEvent

local mouse = player:GetMouse()
local currentCamera = workspace.CurrentCamera

local placementBlocks = game.ReplicatedStorage.PlacementBlocks
local ballBlock = placementBlocks.Block

local currentMode = nil
local currentObject = nil

local currentOrientation = CFrame.new()

local rayInstance = nil
local rayPosition = nil
local rayNormal = nil

local canPlace = false

local connections = {}

local function getRotatedSize(size)
	local instanceSize = currentOrientation * CFrame.new(size) -- times the orientation by default size
	instanceSize = Vector3.new(
		math.abs(instanceSize.X),
		math.abs(instanceSize.Y),
		math.abs(instanceSize.Z) --abs ensure we get a positive number
	)

	return instanceSize
end

-- IMPORTANT PART
local function snapToGrid(size : Vector3)
	
	local instanceSize  = getRotatedSize(size)
	local gridPosition = Vector3.new(
		math.floor(rayPosition.X / STUDS_UNIT + .73) * STUDS_UNIT + rayNormal.X * (instanceSize.X / 2), -- ray position is divided by the studs unit (as grid units)
		math.floor(rayPosition.Y / STUDS_UNIT + .73) * STUDS_UNIT + rayNormal.Y * (instanceSize.Y / 2), -- the + .5 at the end is for rounding with the math.floor
		math.floor(rayPosition.Z / STUDS_UNIT + .73) * STUDS_UNIT + rayNormal.Z * (instanceSize.Z / 2) -- after that multiply by .5 to convert back to real world unit
	)
	-- the half instance size is so it sits at the surface, not inside
	
	return gridPosition
end
-- IMPORTANT PART

local function checkCollisions(object, ignoreList)
	local overParams = OverlapParams.new()
	overParams.FilterType = Enum.RaycastFilterType.Exclude
	
	if currentObject and ignoreList then
		table.insert(ignoreList, currentObject)
	end
	
	for _, player in ipairs(game.Players:GetPlayers()) do
		table.insert(ignoreList, player.Character)
	end
	
	overParams.FilterDescendantsInstances = ignoreList or {}
	
	return workspace:GetPartsInPart(object, overParams)
end

local function mouseRaycast()
	local mousePosition = UserInputService:GetMouseLocation()
	local mouseRay = currentCamera:ViewportPointToRay(mousePosition.X, mousePosition.Y)
	
	local rayParams = RaycastParams.new()
	rayParams.FilterDescendantsInstances = CollectionService:GetTagged("Grid")
	rayParams.FilterType  = Enum.RaycastFilterType.Include
	
	local rayResult = workspace:Raycast(mouseRay.Origin, mouseRay.Direction * 100, rayParams)
	
	return rayResult
end

local function activateBuildMode()		
	currentMode = "Build"
	
	if currentObject then
		currentObject:Destroy()
		currentObject = nil
	end

	-- GHOST OBJECT
	currentObject = ballBlock:Clone()
	currentObject.Name = "GhostObject"
	currentObject.Parent = workspace
	currentObject.Anchored = true
	currentObject.CanCollide = false

	if ballBlock:IsA("Part") then
		currentObject.Transparency = .8
	end
end

local function deactivateBuildMode()	
	if currentObject then
		currentObject:Destroy()
		currentObject = nil
	end
end

local function onToolEquip(mouse)
	activateBuildMode()
	
	connections.Input = UserInputService.InputBegan:Connect(function(input, gp)
		if not gp then
			if input.KeyCode == Enum.KeyCode.E then
				if not currentMode then
					currentMode = "Build"

					if currentObject then
						currentObject:Destroy()
						currentObject = nil
					end

					-- GHOST OBJECT
					currentObject = ballBlock:Clone()
					currentObject.Name = "GhostObject"
					currentObject.Parent = workspace
					currentObject.Anchored = true
					currentObject.CanCollide = false

					if ballBlock:IsA("Part") then
						currentObject.Transparency = .8
					end

				else
					currentMode = nil
				end
			elseif input.KeyCode == Enum.KeyCode.R then
				if currentObject and currentMode then
					currentOrientation = CFrame.Angles(0, math.rad(ANGLE_INCR), 0) * currentOrientation
				end
			elseif input.KeyCode == Enum.KeyCode.T then
				if currentObject and currentMode then
					currentOrientation = CFrame.Angles(0, 0, math.rad(ANGLE_INCR)) * currentOrientation
				end
			elseif input.UserInputType == Enum.UserInputType.MouseButton1 then
				if currentMode then
					if currentObject and canPlace then
						PlacementEvent:FireServer(ballBlock, currentObject.CFrame)
					end
				end
			end
		end
	end)
	
	connections.Heartbeat = RunService.Heartbeat:Connect(function()		
		if currentMode then 

			local raycastResult = mouseRaycast()

			if not raycastResult then return end

			rayInstance = raycastResult.Instance
			rayPosition = raycastResult.Position
			rayNormal = raycastResult.Normal

			if rayNormal and rayPosition and rayInstance and currentObject then
				if currentObject:IsA("Part") then
					currentObject.CFrame = CFrame.new(snapToGrid(currentObject.Size)) * currentOrientation --uses cframes to ensure it takes orientation to account to
				end
			end

			local objectCollision = checkCollisions(currentObject, CollectionService:GetTagged("Grid"))
			
			if currentObject then
				if #objectCollision > 0 then
					currentObject.Color = Color3.fromRGB(255, 0, 0)
					canPlace = false
				else
					currentObject.Color = Color3.fromRGB(0, 255, 0)
					canPlace = true
				end
			end
		else
			if currentObject then
				currentObject:Destroy()
				currentObject = nil
			end
		end
	end)
end

local function onToolUnequip()
	deactivateBuildMode()
		
	for _, v in pairs(connections) do
		if v then
			v:Disconnect()
		end
	end
	connections = {}
	
end

script.Parent.Equipped:Connect(onToolEquip)
script.Parent.Unequipped:Connect(onToolUnequip)

The important lines are on the snap to grid function, i tweaked it a little bit there.

I’d appreciate it if anyone helps
Thanks in advance!

1 Like

Edited for more clarity

This is a problem that has tripped me up more than once. It’s a really confusing issue until you see the underlying logic flaw.

The problem you’re running into is that you’re trying to snap your mouse’s position in the world to an imaginary, universal grid. But the surface you’re building on might not be perfectly aligned with that universal grid, which causes those frustrating out of placed objects.

The best way to fix this is to not use this universal grid and instead create a grid that is local to your floor part.

First, when you enter build mode or claim the plot, your goal should be to map out every single valid grid position on your floor and save them. This pre-calculation ensures every position is perfect. It would look something like this:

local CellGridPositions = {}
local FloorPart = workspace.PlotFloorPart
local STUDS_UNIT = 1

function initializeGrid()
    -- Get the dimensions of the floor in grid cells
    local numCellsX = math.floor(FloorPart.Size.X / STUDS_UNIT)
    local numCellsZ = math.floor(FloorPart.Size.Z / STUDS_UNIT)
    
    -- This is the magic loOoOooop
    for x = 0, numCellsX - 1 do
        for z = 0, numCellsZ - 1 do
            -- Calculate the position for the center of the cell, relative to the floor's own center
            local localPos = CFrame.new(
                (x * STUDS_UNIT) + (STUDS_UNIT / 2) - FloorPart.Size.X / 2, -- Local X
                FloorPart.Size.Y / 2, -- Local Y
                (z * STUDS_UNIT) + (STUDS_UNIT / 2) - FloorPart.Size.Z / 2  -- Local Z
            )
            -- Convert that local position into a real world CFrame and store it
            local worldCFrame = FloorPart.CFrame * localPos
            CellGridPositions[x .. "," .. z] = worldCFrame
        end
    end
end

Step 1: Setup the raycast to detect whenever you click your plots floor. The raycastResult.Position will give you the exact point in the world where the mouse hits the floor’s surface.

Step 2: Take the world hit position from the raycast and convert it into coordinates that are relative to your FloorPart. The :PointToObjectSpace() function is made for this

Step 3: Now that you have a simple, local position, you can use some basic math on it to determine the integer index (like gridX, gridZ) of the cell the mouse is currently over

Step 4: You use that calculated index to create a key (e.g., “10, 15”) and look up the corresponding perfect CFrame from the CellGridPositions map you created earlier. You can then move your preview object directly to this CFrame

Here is an example of what you can do:

local function getPlacementCFrame()
    -- If the mouse is not hitting our floor, we can not place
    if not FloorPart or not rayPosition then 
        return nil
    end

    -- Step 1: Raycast to the Floor


    -- Step 2: Convert the mouse's world hit position to a position that is local to the FloorPart
    local localPos = FloorPart.CFrame:PointToObjectSpace(rayPosition)


    -- Step 3: Calculate which grid cell index this local position corresponds to
    local halfSizeX = FloorPart.Size.X / 2
    local halfSizeZ = FloorPart.Size.Z / 2
    local gridX = math.floor((localPos.X + halfSizeX) / STUDS_UNIT)
    local gridZ = math.floor((localPos.Z + halfSizeZ) / STUDS_UNIT)

    -- Create the key to look up in our pre-calculated grid map.
    local key = gridX .. "," .. gridZ
    

    -- Step 4: Look up the pre-calculated, perfect surface CFrame from our table.
    local surfaceCFrame = CellGridPositions[key]

    if surfaceCFrame then
        -- We need to add a vertical offset based on the object's own size so it sits ON the surface
        local instanceSize = getRotatedSize(currentObject.Size)
        local verticalOffset = CFrame.new(0, instanceSize.Y / 2, 0)
        
        -- Combine the perfect surface position, the vertical offset, and the objects rotation
        local finalCFrame = (surfaceCFrame * verticalOffset) * currentOrientation
        return finalCFrame
    else
        -- The mouse is not on the grid area, so there is no valid CFrame
        return nil
    end
end
3 Likes

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.