Note Raycast System Not Working

Hey everyone!

I am building this Note Placement system using Raycasts where the player can place a note on a wall wherever they click and it will replicate that to the server so everyone can see.
Now on the PC this system is perfect, but on mobile it’s not good.

When I click someplace on mobile, it always places the note like a few studs ABOVE where i click. Also, I can only click at an angle with my camera to the side?

Client Code:

--// Kensizo

--// Services
local Players = game:GetService("Players")
local UserInputService = game:GetService("UserInputService")
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")
local TweenService = game:GetService("TweenService")
local GuiService = game:GetService("GuiService")

--// Variables
local player = Players.LocalPlayer
local tool = script.Parent
local camera = Workspace.CurrentCamera
local EDIT_ATTR = "NoteEditMode"

local PlaceEvent = ReplicatedStorage:WaitForChild("NotePlacementEvent")
local NoteTextEvent = ReplicatedStorage:WaitForChild("NoteTextChangedEvent")
local NoteRemoveEvent = ReplicatedStorage:WaitForChild("NoteRemoveEvent")
local NotePlacementBlocked = ReplicatedStorage:WaitForChild("NotePlacementBlocked")
local NoteTextCommitResult = ReplicatedStorage:WaitForChild("NoteTextCommitResult")
local NotePlacementDenied  = ReplicatedStorage:WaitForChild("NotePlacementDenied")

--// UI
local playerGui = player:WaitForChild("PlayerGui")
local noteGui = playerGui:WaitForChild("NoteUI") :: ScreenGui
local toastLabel: TextLabel? = noteGui:FindFirstChild("Toast", true)
if toastLabel then toastLabel.Visible = false end

--// Toast
local toastToken = 0
local function showToast(message: string, duration: number?)
	if not toastLabel then return end
	toastToken += 1
	local my = toastToken
	toastLabel.Text = message
	toastLabel.Visible = true
	task.delay(duration or 3, function()
		if toastLabel and toastToken == my then
			toastLabel.Visible = false
		end
	end)
end

--// Anonymous
local function isMobileDevice()
	if UserInputService.TouchEnabled and not UserInputService.KeyboardEnabled and not UserInputService.GamepadEnabled then
		return true
	end
	return UserInputService.TouchEnabled and not UserInputService.MouseEnabled
end

local anonButton: TextButton? = noteGui:FindFirstChild("Anonymous", true)
local anonMobileButton: TextButton? = noteGui:FindFirstChild("AnonymousMobile", true)
local isAnonymous = false

local function setAnonVisual(btn: TextButton?)
	if not btn then return end
	local tl = btn:FindFirstChild("TextLabel")
	if tl and tl:IsA("TextLabel") then
		tl.Text = isAnonymous and "Anonymous: ON" or "Anonymous: OFF"
	else
		btn.Text = isAnonymous and "Anonymous: ON" or "Anonymous: OFF"
	end
end

local function applyAnonVisibility()
	local mobile = isMobileDevice()
	if anonButton then anonButton.Visible = not mobile end
	if anonMobileButton then anonMobileButton.Visible = mobile end
	setAnonVisual(anonButton)
	setAnonVisual(anonMobileButton)
end

local function connectAnon(btn: TextButton?)
	if not btn then return end
	btn.Activated:Connect(function()
		isAnonymous = not isAnonymous
		setAnonVisual(anonButton)
		setAnonVisual(anonMobileButton)
	end)
end

applyAnonVisibility()
connectAnon(anonButton)
connectAnon(anonMobileButton)

--// Events
NotePlacementBlocked.OnClientEvent:Connect(function(cap)
	local n = tonumber(cap) or 1
	showToast(string.format("Note limit reached (max %d). Delete a note first.", n), 3)
end)

NotePlacementDenied.OnClientEvent:Connect(function(reason: string?, msg: string?)
	if reason == "filtered" then
		showToast(msg or "Your note was filtered and cannot be placed.", 3)
	else
		showToast(msg or "Cannot place note.", 3)
	end
end)

--// Folders
local placedFolder = Workspace:WaitForChild("PlacedNotes")

--// TextBox Size
local ORIGINAL_TEXTBOX_SIZE = UDim2.new(0.186, 0, 0.559, 0)
local MOBILE_TEXTBOX_SIZE   = UDim2.new(0.165, 0, 0.500, 0)

local function getNoteTextBox(): TextBox?
	local tb = noteGui:FindFirstChildWhichIsA("TextBox", true)
	if not tb then warn("NoteUI: no TextBox descendant found") end
	return tb
end

local function applyTextboxSizing()
	local tb = getNoteTextBox()
	if not tb then return end
	if isMobileDevice() then
		tb.Size = MOBILE_TEXTBOX_SIZE
	else
		tb.Size = ORIGINAL_TEXTBOX_SIZE
	end
end

--// Surface Text
local function applyTextToContainer(container: Instance?, text: string)
	if not container then return end
	local surface: SurfaceGui? = container:FindFirstChildWhichIsA("SurfaceGui", true)
	if not surface then return end
	local textObj = surface:FindFirstChild("TextS", true)
	if textObj and (textObj:IsA("TextLabel") or textObj:IsA("TextBox")) then
		textObj.Text = text
	end
end

--// Raycast
local function getPutOnParts()
	local list = {}
	for _, inst in ipairs(Workspace:GetDescendants()) do
		if inst:IsA("BasePart") and inst.Name == "PutOnPart" then
			table.insert(list, inst)
		end
	end
	return list
end
local putOnParts = getPutOnParts()

local placeParams = RaycastParams.new()
placeParams.FilterType = Enum.RaycastFilterType.Whitelist
placeParams.FilterDescendantsInstances = putOnParts

local deleteParams = RaycastParams.new()
deleteParams.FilterType = Enum.RaycastFilterType.Whitelist
deleteParams.FilterDescendantsInstances = {placedFolder}

--// Preview
local preview: BasePart? = nil
local function makePreview()
	if preview then return end
	local src = tool:WaitForChild("Handle") :: BasePart
	preview = src:Clone()
	preview.Name = "NotePreview"
	preview.Anchored = true
	preview.CanCollide = false
	preview.CanQuery = false
	preview.Material = Enum.Material.Neon
	preview.Color = Color3.fromRGB(0, 255, 0)
	preview.Transparency = 0.5
	for _, d in ipairs(preview:GetDescendants()) do
		if d:IsA("Script") or d:IsA("LocalScript") then
			d.Disabled = true
		end
		if d:IsA("TextLabel") and d.Name == "TextY" then
			d.Visible = false
		end
	end
	preview.Parent = Workspace
end

local function hidePreview()
	if not preview or not preview.Parent then return end
	preview.Transparency = 1
	for _, d in ipairs(preview:GetDescendants()) do
		if d:IsA("SurfaceGui") then d.Enabled = false end
	end
end

local function showPreview()
	if not preview or not preview.Parent then return end
	preview.Transparency = 0.5
	for _, d in ipairs(preview:GetDescendants()) do
		if d:IsA("SurfaceGui") then d.Enabled = true end
	end
end

local function cframeOnSurface(pos: Vector3, normal: Vector3, depth: number): CFrame
	local up = Vector3.yAxis
	if math.abs(normal:Dot(up)) > 0.95 then
		up = Vector3.xAxis
	end
	return CFrame.lookAt(pos + normal * (depth + 0.01), pos + normal, up)
end

local function viewportXYFromScreenXY(screenPos: Vector2): Vector2
	-- GetGuiInset() returns the top-left inset (and sometimes bottom-right; we only need top-left)
	local topLeftInset = GuiService:GetGuiInset()
	return screenPos - topLeftInset
end

local function screenPointToWorld(position: Vector2, targetPart: BasePart)
	local vp = viewportXYFromScreenXY(position)
	local ray = camera:ViewportPointToRay(vp.X, vp.Y)

	-- Define a plane at the wall (targetPart's surface normal)
	local planeNormal = targetPart.CFrame.LookVector
	local planePoint = targetPart.Position

	-- Ray-plane intersection
	local denom = ray.Direction:Dot(planeNormal)
	if math.abs(denom) > 1e-6 then
		local t = (planePoint - ray.Origin):Dot(planeNormal) / denom
		if t > 0 then
			return ray.Origin + ray.Direction * t
		end
	end
	return nil
end

--// Highlight
local deleteHighlight: Highlight? = nil
local function ensureDeleteHighlight()
	if deleteHighlight then return end
	deleteHighlight = Instance.new("Highlight")
	deleteHighlight.Name = "NoteDeleteHighlight"
	deleteHighlight.FillColor = Color3.fromRGB(255, 0, 0)
	deleteHighlight.FillTransparency = 0.6
	deleteHighlight.OutlineColor = Color3.new(0, 0, 0)
	deleteHighlight.OutlineTransparency = 0
	deleteHighlight.DepthMode = Enum.HighlightDepthMode.AlwaysOnTop
	deleteHighlight.Parent = Workspace
end
local function showDeleteHighlight(target: Instance?)
	ensureDeleteHighlight()
	deleteHighlight.Adornee = target
	deleteHighlight.Enabled = target ~= nil
end

--// State
local currentHitPart: BasePart? = nil
local currentHitPos: Vector3? = nil
local currentHitNormal: Vector3? = nil
local currentOwnedNote: BasePart? = nil
local currentForeignNote: BasePart? = nil

local steppingConn: RBXScriptConnection? = nil
local inputConn: RBXScriptConnection? = nil
local textConn: RBXScriptConnection? = nil
local focusConn: RBXScriptConnection? = nil
local commitConn: RBXScriptConnection? = nil

--// Inspect
local inspecting = false
local inspectingNote: BasePart? = nil
local inspectInputConn: RBXScriptConnection? = nil
local inspectRunConn: RBXScriptConnection? = nil
local inspectDistanceConn: RBXScriptConnection? = nil
local savedCam = { type = nil :: any, subject = nil :: any, cf = nil :: CFrame?, fov = nil :: number? }

local function exitInspect()
	if not inspecting then return end
	inspecting = false
	inspectingNote = nil
	if inspectInputConn then inspectInputConn:Disconnect() inspectInputConn = nil end
	if inspectRunConn then inspectRunConn:Disconnect() inspectRunConn = nil end
	if inspectDistanceConn then inspectDistanceConn:Disconnect() inspectDistanceConn = nil end
	camera.CameraType = savedCam.type or Enum.CameraType.Custom
	camera.CameraSubject = savedCam.subject
	if savedCam.cf then camera.CFrame = savedCam.cf end
	if savedCam.fov then camera.FieldOfView = savedCam.fov end
end

local function enterInspect(note: BasePart)
	if not note or not note.Parent then return end
	savedCam.type = camera.CameraType
	savedCam.subject = camera.CameraSubject
	savedCam.cf = camera.CFrame
	savedCam.fov = camera.FieldOfView
	inspecting = true
	inspectingNote = note
	camera.CameraType = Enum.CameraType.Scriptable
	local cf = note.CFrame
	local forward = cf.LookVector
	local up = cf.UpVector
	local dist = math.max(4, note.Size.Z * 8)
	local pos = cf.Position + forward * dist + up * 0.15
	local target = CFrame.lookAt(pos, cf.Position, up)
	TweenService:Create(camera, TweenInfo.new(0.25, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
		CFrame = target,
		FieldOfView = 50
	}):Play()
	inspectInputConn = UserInputService.InputBegan:Connect(function(input, gp)
		if gp then return end
		if input.UserInputType == Enum.UserInputType.MouseButton1
			or input.UserInputType == Enum.UserInputType.Touch
			or input.UserInputType == Enum.UserInputType.Gamepad1
			or input.KeyCode == Enum.KeyCode.W
			or input.KeyCode == Enum.KeyCode.A
			or input.KeyCode == Enum.KeyCode.S
			or input.KeyCode == Enum.KeyCode.D
			or input.KeyCode == Enum.KeyCode.Space then
			exitInspect()
		end
	end)
	local humanoid = player.Character and player.Character:FindFirstChildOfClass("Humanoid")
	if humanoid then
		inspectRunConn = humanoid.Running:Connect(function(speed)
			if speed > 0 then exitInspect() end
		end)
	end
	inspectDistanceConn = RunService.Heartbeat:Connect(function()
		if not inspecting or not inspectingNote or not inspectingNote.Parent then
			exitInspect()
			return
		end
		local hrp = player.Character and player.Character:FindFirstChild("HumanoidRootPart")
		if hrp and (hrp.Position - inspectingNote.Position).Magnitude > 20 then
			exitInspect()
		end
	end)
end

--// Text Sync
local function startTextSync()
	if not isMobileDevice() then
		makePreview()
	end

	local tb = getNoteTextBox()
	if not tb then return end
	tb.ClearTextOnFocus = false
	tb.Focused:Connect(function()
		tb.CursorPosition = #tb.Text + 1
	end)

	local serverText = tool:GetAttribute("NoteText")
	if type(serverText) == "string" and #serverText > 0 then
		tb.Text = serverText
	end

	-- Only apply to preview if it exists
	if preview then
		applyTextToContainer(preview, tb.Text)
	end

	textConn = tb:GetPropertyChangedSignal("Text"):Connect(function()
		if preview then
			applyTextToContainer(preview, tb.Text)
		end
	end)

	focusConn = tb.FocusLost:Connect(function(_enterPressed)
		NoteTextEvent:FireServer(tb.Text)
	end)

	commitConn = NoteTextCommitResult.OnClientEvent:Connect(function(filteredText: string)
		local box = getNoteTextBox()
		if box then box.Text = filteredText end
		if preview then
			applyTextToContainer(preview, filteredText)
		end
	end)
end

local function stopTextSync()
	if textConn   then textConn:Disconnect()   textConn = nil end
	if focusConn  then focusConn:Disconnect()  focusConn = nil end
	if commitConn then commitConn:Disconnect() commitConn = nil end
end

--// Zoom Lock
local originalMinZoom = player.CameraMinZoomDistance
local originalMaxZoom = player.CameraMaxZoomDistance
local zoomLocked = false

local function currentSubjectCFrame(): CFrame?
	local subj = camera.CameraSubject
	if typeof(subj) == "Instance" then
		if subj:IsA("Humanoid") then
			local hrp = subj.Parent and subj.Parent:FindFirstChild("HumanoidRootPart")
			if hrp and hrp:IsA("BasePart") then return hrp.CFrame end
		elseif subj:IsA("BasePart") then
			return subj.CFrame
		end
	end
	return nil
end

local function getCurrentZoomDistance(): number
	local cf = currentSubjectCFrame()
	if cf then
		return (camera.CFrame.Position - cf.Position).Magnitude
	end
	return math.clamp(originalMaxZoom or 128, 0.5, 1000)
end

local function lockZoom()
	if zoomLocked then return end
	originalMinZoom = player.CameraMinZoomDistance
	originalMaxZoom = player.CameraMaxZoomDistance
	local d = math.clamp(getCurrentZoomDistance(), originalMinZoom or 0.5, originalMaxZoom or 128)
	player.CameraMinZoomDistance = d
	player.CameraMaxZoomDistance = d
	zoomLocked = true
end

local function unlockZoom()
	if not zoomLocked then return end
	player.CameraMinZoomDistance = originalMinZoom or 0.5
	player.CameraMaxZoomDistance = originalMaxZoom or 128
	zoomLocked = false
end

--// Loop
local function findPlacedNoteRoot(inst: Instance?): BasePart?
	local cur = inst
	while cur and cur ~= placedFolder do
		if cur:IsA("BasePart") and cur.Name == "PlacedNote" then
			return cur
		end
		cur = cur.Parent
	end
	return nil
end

local function startPreviewLoop()
	if isMobileDevice() then
		-- 🔹 MOBILE MODE (no preview at all)
		putOnParts = getPutOnParts()
		placeParams.FilterDescendantsInstances = putOnParts

		-- make sure delete ray only checks real notes
		deleteParams.FilterDescendantsInstances = {placedFolder}

		inputConn = UserInputService.TouchTap:Connect(function(touchPositions, gp)
			if gp then return end
			if not touchPositions or #touchPositions == 0 then return end

			local location = touchPositions[1]

			-- UI hit test (raw coords)
			local guiObjects = playerGui:GetGuiObjectsAtPosition(location.X, location.Y)
			for _, gui in ipairs(guiObjects) do
				if gui:IsDescendantOf(noteGui) then
					return
				end
			end

			-- Build ray from touch (with inset corrected)
			local vp = viewportXYFromScreenXY(location)
			local ray = camera:ViewportPointToRay(vp.X, vp.Y)

			-- DELETE check
			local deleteResult = Workspace:Raycast(ray.Origin, ray.Direction * 1000, deleteParams)
			if deleteResult and deleteResult.Instance then
				local noteRoot = findPlacedNoteRoot(deleteResult.Instance)
				if noteRoot and noteRoot.Parent then
					local owner = noteRoot:GetAttribute("OwnerUserId")
					if owner == player.UserId then
						NoteRemoveEvent:FireServer(noteRoot)
						return
					end
				end
			end

			-- PLACE check
			local placeResult = Workspace:Raycast(ray.Origin, ray.Direction * 1000, placeParams)
			if placeResult and placeResult.Instance and placeResult.Instance:IsA("BasePart") then
				local tb = getNoteTextBox()
				local rawText = tb and tb.Text or ""
				local depth = (tool:WaitForChild("Handle").Size.Z * 0.5)

				-- ✅ Use Raycast hit Position directly
				PlaceEvent:FireServer(placeResult.Instance, placeResult.Position, placeResult.Normal, isAnonymous, rawText, true)
			end
		end)

	else
		-- 🔹 PC MODE (with preview & hover system)
		makePreview()
		showPreview()
		putOnParts = getPutOnParts()
		placeParams.FilterDescendantsInstances = putOnParts

		steppingConn = RunService.RenderStepped:Connect(function()
			if not preview or not preview.Parent then
				makePreview()
			end

			if inspecting then
				hidePreview()
				showDeleteHighlight(nil)
				return
			end

			local mpos = UserInputService:GetMouseLocation()
			local ray = camera:ViewportPointToRay(mpos.X, mpos.Y)

			currentOwnedNote, currentForeignNote = nil, nil
			local deleteResult = Workspace:Raycast(ray.Origin, ray.Direction * 1000, deleteParams)
			if deleteResult and deleteResult.Instance then
				local noteRoot = findPlacedNoteRoot(deleteResult.Instance)
				if noteRoot then
					local owner = noteRoot:GetAttribute("OwnerUserId")
					if owner == player.UserId then
						currentOwnedNote = noteRoot
					else
						currentForeignNote = noteRoot
					end
				end
			end

			if currentOwnedNote then
				showDeleteHighlight(currentOwnedNote)
				hidePreview()
				currentHitPart, currentHitPos, currentHitNormal = nil, nil, nil
				return
			else
				showDeleteHighlight(nil)
			end

			local placeResult = Workspace:Raycast(ray.Origin, ray.Direction * 1000, placeParams)
			if placeResult and placeResult.Instance and placeResult.Instance:IsA("BasePart") then
				currentHitPart = placeResult.Instance
				currentHitPos = placeResult.Position
				currentHitNormal = placeResult.Normal
				if preview then
					local depth = preview.Size.Z * 0.5
					preview.CFrame = cframeOnSurface(currentHitPos, currentHitNormal, depth)
					showPreview()
				end
			else
				currentHitPart, currentHitPos, currentHitNormal = nil, nil, nil
				hidePreview()
			end
		end)

		inputConn = UserInputService.InputBegan:Connect(function(input, gp)
			if gp then return end
			if inspecting then return end

			local ftb = UserInputService:GetFocusedTextBox()
			if ftb and ftb:IsDescendantOf(noteGui) then
				ftb:ReleaseFocus()
			end

			if input.UserInputType == Enum.UserInputType.MouseButton1 then
				if currentOwnedNote and currentOwnedNote.Parent then
					NoteRemoveEvent:FireServer(currentOwnedNote)
					showDeleteHighlight(nil)
					currentOwnedNote = nil
					return
				end

				if currentForeignNote and currentForeignNote.Parent then
					return
				end

				if currentHitPart and currentHitPos and currentHitNormal then
					local tb = getNoteTextBox()
					local rawText = tb and tb.Text or ""
					PlaceEvent:FireServer(currentHitPart, currentHitPos, currentHitNormal, isAnonymous, rawText, false)
				end
			end
		end)
	end
end

local function stopPreviewLoop()
	if steppingConn then steppingConn:Disconnect() steppingConn = nil end
	if inputConn then inputConn:Disconnect() inputConn = nil end

	if not isMobileDevice() then
		hidePreview()
		showDeleteHighlight(nil)
		currentOwnedNote = nil
		currentForeignNote = nil
	end

	exitInspect()
end

--// Lifecycle
tool.Equipped:Connect(function()
	player:SetAttribute(EDIT_ATTR, true)
	lockZoom()
	startPreviewLoop()
	startTextSync()
	if noteGui then noteGui.Enabled = true end
	applyAnonVisibility()
	applyTextboxSizing()
end)

tool.Unequipped:Connect(function()
	player:SetAttribute(EDIT_ATTR, false)
	unlockZoom()
	stopPreviewLoop()
	stopTextSync()
	if noteGui then noteGui.Enabled = false end
end)

tool.AncestryChanged:Connect(function(_, parent)
	if not parent then
		player:SetAttribute(EDIT_ATTR, false)
		unlockZoom()
		stopPreviewLoop()
		stopTextSync()
		if noteGui then noteGui.Enabled = false end
		if preview then preview:Destroy() preview = nil end
	end
end)

Server Code:

-- ServerScriptService.NotePlacementServer

--========================
-- Services
--========================
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local Workspace = game:GetService("Workspace")
local TextService = game:GetService("TextService")
local MarketplaceService = game:GetService("MarketplaceService")
local Debris = game:GetService("Debris")

--========================
-- Gamepass & Caps
--========================
local GAMEPASS_ID = 1405782245
local BASE_CAP = 1
local BONUS_FROM_PASS = 5
local MAX_TOTAL_NOTES = 100

local function safeOwnsPass(userId: number, passId: number): boolean
	local ok, has = pcall(MarketplaceService.UserOwnsGamePassAsync, MarketplaceService, userId, passId)
	if not ok then
		warn("[Notes] UserOwnsGamePassAsync failed:", userId, passId)
	end
	return ok and has or false
end

local function computeCapFor(player: Player): number
	return safeOwnsPass(player.UserId, GAMEPASS_ID) and (BASE_CAP + BONUS_FROM_PASS) or BASE_CAP
end

local function getNoteCap(player: Player): number
	local capAttr = player:GetAttribute("NoteCap")
	if typeof(capAttr) == "number" then
		return capAttr
	end
	local cap = computeCapFor(player)
	player:SetAttribute("NoteCap", cap)
	return cap
end

Players.PlayerAdded:Connect(function(plr)
	plr:SetAttribute("NoteCap", computeCapFor(plr))
end)

--========================
-- Remotes
--========================
local PlaceEvent = ReplicatedStorage:FindFirstChild("NotePlacementEvent")
if not PlaceEvent then
	PlaceEvent = Instance.new("RemoteEvent")
	PlaceEvent.Name = "NotePlacementEvent"
	PlaceEvent.Parent = ReplicatedStorage
end

local NoteTextEvent = ReplicatedStorage:FindFirstChild("NoteTextChangedEvent")
if not NoteTextEvent then
	NoteTextEvent = Instance.new("RemoteEvent")
	NoteTextEvent.Name = "NoteTextChangedEvent"
	NoteTextEvent.Parent = ReplicatedStorage
end

local NoteRemoveEvent = ReplicatedStorage:FindFirstChild("NoteRemoveEvent")
if not NoteRemoveEvent then
	NoteRemoveEvent = Instance.new("RemoteEvent")
	NoteRemoveEvent.Name = "NoteRemoveEvent"
	NoteRemoveEvent.Parent = ReplicatedStorage
end

local NotePlacementBlocked = ReplicatedStorage:FindFirstChild("NotePlacementBlocked")
if not NotePlacementBlocked then
	NotePlacementBlocked = Instance.new("RemoteEvent")
	NotePlacementBlocked.Name = "NotePlacementBlocked"
	NotePlacementBlocked.Parent = ReplicatedStorage
end

local NoteTextCommitResult = ReplicatedStorage:FindFirstChild("NoteTextCommitResult")
if not NoteTextCommitResult then
	NoteTextCommitResult = Instance.new("RemoteEvent")
	NoteTextCommitResult.Name = "NoteTextCommitResult"
	NoteTextCommitResult.Parent = ReplicatedStorage
end

local NotePlacementDenied = ReplicatedStorage:FindFirstChild("NotePlacementDenied")
if not NotePlacementDenied then
	NotePlacementDenied = Instance.new("RemoteEvent")
	NotePlacementDenied.Name = "NotePlacementDenied"
	NotePlacementDenied.Parent = ReplicatedStorage
end

--========================
-- World / Folders
--========================
local placedFolder = Workspace:FindFirstChild("PlacedNotes")
if not placedFolder then
	placedFolder = Instance.new("Folder")
	placedFolder.Name = "PlacedNotes"
	placedFolder.Parent = Workspace
end

--========================
-- Tunables
--========================
local MAX_PLACE_DISTANCE = 20
local MAX_REMOVE_DISTANCE = 25
--local NOTE_LIFETIME = 60 * 60
local NOTE_LIFETIME = 3600
local OWNER_ABSENCE_SECONDS = 1800

--========================
-- Utilities
--========================
local function isPutOnPart(part: BasePart?): boolean
	return typeof(part) == "Instance"
		and part:IsA("BasePart")
		and part.Name == "PutOnPart"
end

local function filterFor(player: Player, raw: any): string
	local text = tostring(raw or ""):sub(1, 120)
	local ok, filtered = pcall(function()
		local res = TextService:FilterStringAsync(text, player.UserId)
		return res:GetNonChatStringForBroadcastAsync()
	end)
	return (ok and filtered) or ""
end

local function isBlank(s: string): boolean
	return s == nil or s == "" or s:match("^%s*$") ~= nil
end

local function containsTripletHashes(s: any): boolean
	return type(s) == "string" and s:find("###", 1, true) ~= nil
end

local function pointOnPartSurface(part: BasePart, worldPos: Vector3): boolean
	local lp = part.CFrame:PointToObjectSpace(worldPos)
	local sx, sy, sz = part.Size.X * 0.5, part.Size.Y * 0.5, part.Size.Z * 0.5
	local eps = 0.05
	local inX = lp.X > -sx - eps and lp.X < sx + eps
	local inY = lp.Y > -sy - eps and lp.Y < sy + eps
	local atAnyFace =
		math.abs(lp.Z - (-sz)) <= eps or math.abs(lp.Z - (sz)) <= eps or
		math.abs(lp.X - (-sx)) <= eps or math.abs(lp.X - (sx)) <= eps or
		math.abs(lp.Y - (-sy)) <= eps or math.abs(lp.Y - (sy)) <= eps
	return inX and inY and atAnyFace
end

local function cframeOnSurface(pos: Vector3, normal: Vector3, depth: number): CFrame
	local up = Vector3.yAxis
	if math.abs(normal:Dot(up)) > 0.95 then
		up = Vector3.xAxis
	end
	return CFrame.lookAt(pos + normal * (depth + 0.01), pos + normal, up)
end

local function applyTextToContainer(container: Instance?, text: string)
	if not container then return end
	local surface: SurfaceGui? = container:FindFirstChildWhichIsA("SurfaceGui", true)
	if not surface then return end
	local textObj = surface:FindFirstChild("TextS", true)
	if textObj and (textObj:IsA("TextLabel") or textObj:IsA("TextBox")) then
		textObj.Text = text
	end
end

local function applyAuthorToContainer(container: Instance?, authorName: string)
	if not container then return end
	local surface: SurfaceGui? = container:FindFirstChildWhichIsA("SurfaceGui", true)
	if not surface then return end
	local y = surface:FindFirstChild("TextY", true)
	if y and (y:IsA("TextLabel") or y:IsA("TextBox")) then
		y.Visible = true
		y.Text = "-" .. tostring(authorName)
	end
end

local function getPlayerNotes(userId: number): {BasePart}
	local notes = {}
	for _, inst in ipairs(placedFolder:GetChildren()) do
		if inst:IsA("BasePart")
			and inst.Name == "PlacedNote"
			and inst:GetAttribute("OwnerUserId") == userId then
			table.insert(notes, inst)
		end
	end
	return notes
end

local function getTotalNotesCount(): number
	local n = 0
	for _, inst in ipairs(placedFolder:GetChildren()) do
		if inst:IsA("BasePart") and inst.Name == "PlacedNote" then
			n += 1
		end
	end
	return n
end

local function isAtCap(player: Player): boolean
	return #getPlayerNotes(player.UserId) >= getNoteCap(player)
end

local function scheduleExpiry(note: BasePart)
	if not note or not note.Parent then return end
	local now = os.time()
	local placedAt = tonumber(note:GetAttribute("PlacedAt")) or now
	local expiresAt = tonumber(note:GetAttribute("ExpiresAt"))
	if not expiresAt then
		expiresAt = placedAt + NOTE_LIFETIME
		note:SetAttribute("ExpiresAt", expiresAt)
	end
	local remaining = math.max(0, expiresAt - now)
	if remaining <= 0 then
		note:Destroy()
	else
		Debris:AddItem(note, remaining)
	end
end

for _, inst in ipairs(placedFolder:GetChildren()) do
	if inst:IsA("BasePart") and inst.Name == "PlacedNote" then
		scheduleExpiry(inst)
	end
end

--========================
-- Absence-Based Removal
--========================
local absenceToken: { [number]: number } = {}

local function bumpAbsenceToken(userId: number)
	absenceToken[userId] = (absenceToken[userId] or 0) + 1
end

local function currentAbsenceToken(userId: number): number
	return absenceToken[userId] or 0
end

local function destroyAllNotesFor(userId: number)
	for _, note in ipairs(getPlayerNotes(userId)) do
		if note and note.Parent then
			note:Destroy()
		end
	end
end

local function scheduleAbsenceRemoval(userId: number, delaySeconds: number)
	local token = currentAbsenceToken(userId)
	task.delay(delaySeconds, function()
		if currentAbsenceToken(userId) == token and not Players:GetPlayerByUserId(userId) then
			destroyAllNotesFor(userId)
		end
	end)
end

Players.PlayerAdded:Connect(function(plr)
	bumpAbsenceToken(plr.UserId)
end)

Players.PlayerRemoving:Connect(function(plr)
	scheduleAbsenceRemoval(plr.UserId, OWNER_ABSENCE_SECONDS)
end)

--========================
-- Text Replication
--========================
NoteTextEvent.OnServerEvent:Connect(function(player: Player, rawText)
	local text = filterFor(player, rawText)

	local char = player.Character
	if not char then return end
	local tool = char:FindFirstChild("Note") or char:FindFirstChildOfClass("Tool")
	if not tool then return end

	tool:SetAttribute("NoteText", text)

	local handle = tool:FindFirstChild("Handle")
	applyTextToContainer(handle, text)

	if isBlank(text) or containsTripletHashes(text) then
		NotePlacementDenied:FireClient(player, "filtered", "Your note was filtered and cannot be placed.")
	end

	NoteTextCommitResult:FireClient(player, text)
end)

--========================
-- Placement
--========================
PlaceEvent.OnServerEvent:Connect(function(player, targetPart, hitPos, hitNormal, isAnonymous, rawClientText, isMobile)
	if not isPutOnPart(targetPart) then return end

	local char = player.Character
	if not char then return end
	local hrp = char:FindFirstChild("HumanoidRootPart")
	if not hrp then return end

	local tool = char:FindFirstChild("Note") or char:FindFirstChildOfClass("Tool")
	if not tool then return end
	local srcHandle = tool:FindFirstChild("Handle")
	if not srcHandle or not srcHandle:IsA("BasePart") then return end
	local sg = srcHandle:FindFirstChildOfClass("SurfaceGui")
	if not sg or not sg:FindFirstChild("TextS") then return end

	if (hrp.Position - hitPos).Magnitude > MAX_PLACE_DISTANCE then return end
	if not pointOnPartSurface(targetPart, hitPos) then return end

	if getTotalNotesCount() >= MAX_TOTAL_NOTES then
		NotePlacementDenied:FireClient(player, "globalcap", ("Max of %d notes reached. Try again later."):format(MAX_TOTAL_NOTES))
		return
	end

	if isAtCap(player) then
		if NotePlacementBlocked then
			NotePlacementBlocked:FireClient(player, getNoteCap(player))
		end
		return
	end

	local finalText = tostring(rawClientText or "")
	if finalText == "" then
		finalText = tool:GetAttribute("NoteText")
		if type(finalText) ~= "string" or #finalText == 0 then
			local textObj = sg:FindFirstChild("TextS")
			finalText = (textObj and (textObj:IsA("TextLabel") or textObj:IsA("TextBox")) and textObj.Text) or ""
		end
	end

	finalText = filterFor(player, finalText)

	if isBlank(finalText) or containsTripletHashes(finalText) then
		if NotePlacementDenied then
			NotePlacementDenied:FireClient(player, "filtered", "Your note was filtered and cannot be placed.")
		end
		return
	end

	tool:SetAttribute("NoteText", finalText)
	applyTextToContainer(srcHandle, finalText)

	local placed: BasePart = srcHandle:Clone()
	placed.Name = "PlacedNote"
	placed.Anchored = false
	placed.CanCollide = false
	placed.Parent = placedFolder

	applyTextToContainer(placed, finalText)

	local authorName = (isAnonymous == true) and "Anonymous" or player.Name
	applyAuthorToContainer(placed, authorName)
	
	local depth = placed.Size.Z * 0.5
	local baseCF = cframeOnSurface(hitPos, hitNormal.Unit, depth)

	--if isMobile then
	--	-- shift down along world Y
	--	placed.CFrame = baseCF + Vector3.new(0, -3.5, 0)
	--else
	--	placed.CFrame = baseCF
	--end
	
	placed.CFrame = baseCF

	local weld = Instance.new("WeldConstraint")
	weld.Part0 = targetPart
	weld.Part1 = placed
	weld.Parent = placed

	local now = os.time()
	placed:SetAttribute("OwnerUserId", player.UserId)
	placed:SetAttribute("Anonymous", isAnonymous == true)
	placed:SetAttribute("PlacedAt", now)
	placed:SetAttribute("ExpiresAt", now + NOTE_LIFETIME)

	Debris:AddItem(placed, NOTE_LIFETIME)
end)

--========================
-- Owner-Only Removal
--========================
NoteRemoveEvent.OnServerEvent:Connect(function(player: Player, targetNote: Instance)
	if not targetNote or not targetNote:IsA("BasePart") then return end
	if not targetNote:IsDescendantOf(placedFolder) then return end
	if targetNote.Name ~= "PlacedNote" then return end

	-- ✅ Only let the owner delete their own notes
	if targetNote:GetAttribute("OwnerUserId") ~= player.UserId then
		return
	end

	-- ✅ Safety distance check against the note's *real position*
	local char = player.Character
	local hrp = char and char:FindFirstChild("HumanoidRootPart")
	if hrp and (hrp.Position - targetNote.Position).Magnitude > MAX_REMOVE_DISTANCE then
		return
	end

	-- 🔥 Destroy the actual note part you tapped
	targetNote:Destroy()
end)

Video:

Help is GREATLY appreciated. Thank you!
-sarvv

Have you considered the possibility that this is simply a studio thing? Have you tested with an actual mobile device?