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