You can write your topic however you want, but you need to answer these questions:
- 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).
- 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.
- 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)