Confused On Block Placing System

You can write your topic however you want, but you need to answer these questions:

  1. What do you want to achieve? Keep it simple and clear!

I am making a bedwars game, similar to Roblox BedWars.
Currently I’m making a block placing system, where you can look on the side of a block to place a block there. The block is supposed to be placed near a block, and if the position of your cursor is invalid, it should place a block as close as possible to it without failing any of the checks.

Like in BedWars, if you look into the void or down when the floor is far away, you should place the block near the player so they can bridge.

Here’s a video showing an example of a similar system (I couldn’t figure out how to make the vide o work).

  1. What is the issue? Include screenshots / videos if possible!

Placing in side of blocks or sometimes besides them causes the placement to bug or jitter, and looking into the void just doesn’t work at all.

  1. What solutions have you tried so far? Did you look for solutions on the Creator Hub?

I tried looking on youtube, researching and trying out fixes. As a last resort I tried asking ChatGPT, which unsurprisingly couldn’t help.

Here’s my code:

--server
-- Server: validate placement, snap server-side, collision check, place block
local RS = game:GetService("ReplicatedStorage")
local PlaceEvent = RS:WaitForChild("PlaceBlockEvent")
local BlockFolder = workspace:FindFirstChild("PlacedBlocks") or Instance.new("Folder", workspace)
BlockFolder.Name = "PlacedBlocks"

local GRID_SIZE = 4
local MAX_PLACE_DISTANCE = 60 -- max allowed distance from player root to place (adjust)

-- server-side snap
local function snapToGrid(pos)
	return Vector3.new(
		math.floor(pos.X / GRID_SIZE + 0.5) * GRID_SIZE,
		math.floor(pos.Y / GRID_SIZE + 0.5) * GRID_SIZE,
		math.floor(pos.Z / GRID_SIZE + 0.5) * GRID_SIZE
	)
end

-- server-side occupancy check
local function isPositionFreeServer(center)
	local overlapSize = Vector3.new(GRID_SIZE * 0.95, GRID_SIZE * 0.95, GRID_SIZE * 0.95)
	local params = OverlapParams.new()
	params.FilterType = Enum.RaycastFilterType.Whitelist -- we'll filter manually after
	params.FilterDescendantsInstances = {}

	-- get parts in cell
	local parts = workspace:GetPartBoundsInBox(CFrame.new(center), overlapSize, params)
	for _, p in ipairs(parts) do
		if p and p.Parent then
			if p.Parent:FindFirstChildWhichIsA("Humanoid") then
				return false
			end
			if p.Name == "Block" or p:GetAttribute("GridBlock") == true then
				return false
			end
		end
	end
	return true
end

PlaceEvent.OnServerEvent:Connect(function(player, requestedPos)
	-- basic validation
	if typeof(requestedPos) ~= "Vector3" then return end
	local root = player.Character and player.Character:FindFirstChild("HumanoidRootPart")
	if not root then return end
	if (root.Position - requestedPos).Magnitude > MAX_PLACE_DISTANCE then
		return
	end

	-- snap
	local snapped = snapToGrid(requestedPos)

	-- final occupancy check
	if not isPositionFreeServer(snapped) then
		return
	end

	-- place the block
	local blockTemplate = RS:FindFirstChild("BlockAssets") and RS.BlockAssets:FindFirstChild("Block")
	if not blockTemplate then
		warn("No block template found in ReplicatedStorage.BlockAssets.Block")
		return
	end

	local newBlock = blockTemplate:Clone()
	newBlock.Name = "Block"
	-- ensure size/pivot/anchored is correct server-side
	if newBlock:IsA("BasePart") then
		newBlock.Size = Vector3.new(GRID_SIZE, GRID_SIZE, GRID_SIZE)
		newBlock.Anchored = true
		newBlock.CanCollide = true
		newBlock.Position = snapped
		newBlock:SetAttribute("GridBlock", true)
		newBlock.Parent = BlockFolder
	else
		-- if it's a model, set primary part & position
		if newBlock.PrimaryPart then
			for _, part in ipairs(newBlock:GetDescendants()) do
				if part:IsA("BasePart") then
					part.Anchored = true
					part.CanCollide = true
				end
			end
			newBlock:SetPrimaryPartCFrame(CFrame.new(snapped))
			newBlock.Parent = BlockFolder
			newBlock:SetAttribute("GridBlock", true)
		else
			-- fallback
			newBlock.Parent = BlockFolder
			newBlock:SetAttribute("GridBlock", true)
			warn("Block model had no PrimaryPart; placed without exact sizing.")
		end
	end
end)
--client
-- LocalScript: Grid placement + ghost preview + nearest-free search
local player = game.Players.LocalPlayer
local mouse = player:GetMouse()
local UIS = game:GetService("UserInputService")
local RunService = game:GetService("RunService")
local RS = game:GetService("ReplicatedStorage")
local PlaceEvent = RS:WaitForChild("PlaceBlockEvent")

local GRID_SIZE = 4
local SEARCH_RADIUS = 3 -- blocks distance to search for alternatives

-- Ghost block
local ghost = Instance.new("Part")
ghost.Name = "GhostBlock"
ghost.Size = Vector3.new(GRID_SIZE, GRID_SIZE, GRID_SIZE)
ghost.Anchored = true
ghost.CanCollide = false
ghost.Material = Enum.Material.Neon
ghost.Transparency = 0.5
ghost.Parent = workspace

-- helper: snap world position to the grid cell center
local function snapToGrid(pos)
	return Vector3.new(
		math.floor(pos.X / GRID_SIZE + 0.5) * GRID_SIZE,
		math.floor(pos.Y / GRID_SIZE + 0.5) * GRID_SIZE,
		math.floor(pos.Z / GRID_SIZE + 0.5) * GRID_SIZE
	)
end

-- helper: quantize a normal to axis-aligned normal (x/y/z = -1/0/1)
local function quantizeNormal(n)
	local ax, ay, az = math.abs(n.X), math.abs(n.Y), math.abs(n.Z)
	if ax >= ay and ax >= az then
		return Vector3.new(n.X >= 0 and 1 or -1, 0, 0)
	elseif ay >= ax and ay >= az then
		return Vector3.new(0, n.Y >= 0 and 1 or -1, 0)
	else
		return Vector3.new(0, 0, n.Z >= 0 and 1 or -1)
	end
end

-- check if a grid cell at 'center' is free (no players, no other placed blocks)
local function isPositionFree(center)
	local overlapSize = Vector3.new(GRID_SIZE * 0.9, GRID_SIZE * 0.9, GRID_SIZE * 0.9)
	local params = OverlapParams.new()
	params.FilterDescendantsInstances = {player.Character, ghost}
	params.FilterType = Enum.RaycastFilterType.Blacklist

	local parts = workspace:GetPartBoundsInBox(CFrame.new(center), overlapSize, params)
	for _, p in ipairs(parts) do
		-- if it's the local player's character or ghost it will be filtered already
		if p and p.Parent then
			-- if this is any player's character part
			if p.Parent:FindFirstChildWhichIsA("Humanoid") then
				return false
			end
			-- if this looks like an existing grid block (we'll detect by Name/attribute)
			if p.Name == "Block" or p:GetAttribute("GridBlock") == true then
				return false
			end
		end
	end
	return true
end

-- Generate candidate offsets (in grid steps) within SEARCH_RADIUS and sort by distance to 'referencePoint'
local function generateCandidates(referenceCenter, referencePoint)
	local candidates = {}
	local maxR = SEARCH_RADIUS
	for dx = -maxR, maxR do
		for dy = -maxR, maxR do
			for dz = -maxR, maxR do
				local offset = Vector3.new(dx, dy, dz)
				local pos = referenceCenter + offset * GRID_SIZE
				local distSq = (pos - referencePoint).Magnitude ^ 2
				table.insert(candidates, {pos = pos, d = distSq})
			end
		end
	end
	table.sort(candidates, function(a,b) return a.d < b.d end)
	return candidates
end

-- find nearest free spot starting from desiredCenter. referencePoint is where we want it to be near (cursor hit)
local function findNearestFreePosition(desiredCenter, referencePoint)
	if isPositionFree(desiredCenter) then
		return desiredCenter
	end

	local candidates = generateCandidates(desiredCenter, referencePoint)
	for _, c in ipairs(candidates) do
		-- skip the original position, we already tested it
		if isPositionFree(c.pos) then
			return c.pos
		end
	end
	return nil
end

-- get placement position: uses hit Instance center if it's a grid block, quantizes normals, and finds nearest free cell
local function getPlacementPosition()
	local rayParams = RaycastParams.new()
	rayParams.FilterDescendantsInstances = {player.Character, ghost}
	rayParams.FilterType = Enum.RaycastFilterType.Blacklist

	local unitRay = mouse.UnitRay
	local result = workspace:Raycast(unitRay.Origin, unitRay.Direction * 1000, rayParams)

	if result then
		local n = quantizeNormal(result.Normal)
		local referencePoint = result.Position

		-- if we hit a grid block, use its center as the base; otherwise snap the hit point
		local hitInst = result.Instance
		local baseCenter
		if hitInst and (hitInst.Name == "Block" or hitInst:GetAttribute("GridBlock") == true) and hitInst:IsA("BasePart") then
			baseCenter = hitInst.Position
		else
			baseCenter = snapToGrid(result.Position)
		end

		-- the desired center is the adjacent cell in the quantized normal direction
		local desiredCenter = baseCenter + n * GRID_SIZE
		return findNearestFreePosition(desiredCenter, referencePoint)
	else
		else
    -- Bridging into void (use mouse direction instead of player forward)
    local origin = unitRay.Origin
    local dir = unitRay.Direction * 100

    -- Step outward from the mouse ray in grid increments until we find a block beneath
    for i = 1, 20 do
        local stepPos = origin + dir.Unit * (i * GRID_SIZE)
        local downResult = workspace:Raycast(stepPos, Vector3.new(0, -100, 0), rayParams)

        if downResult then
            local basePos = snapToGrid(downResult.Position) + Vector3.new(0, GRID_SIZE/2, 0)
            return findNearestFreePosition(basePos)
        end
    end
end
	end

	return nil
end

-- Render ghost and update color
RunService.RenderStepped:Connect(function()
	local pos = getPlacementPosition()
	if pos then
		ghost.Position = pos
		ghost.Transparency = 0.4
		ghost.Color = Color3.new(0,1,0)
	else
		-- show red and stay where camera is (or hide)
		ghost.Transparency = 0.6
		ghost.Color = Color3.new(1,0,0)
	end
end)

-- Place block on click
UIS.InputBegan:Connect(function(input, gpe)
	if gpe then return end
	if input.UserInputType == Enum.UserInputType.MouseButton1 then
		local pos = getPlacementPosition()
		if pos then
			PlaceEvent:FireServer(pos)
		end
	end
end)
3 Likes

I was wondering this exact same question earlier today. Did you ever find a solution that worked well for you and your project?