How to play spatial sounds from the server

The server co-ordinates events for me, so I entrust it with synchronising sound playback.

I have 2 players and I want both to hear effects, but I want these sounds to be localised, like player footsteps for example. These are spatial sounds.

I hear a lot of people say you can’t play sounds from the server. That is wrong, you can. At the very least you can when they are loaded inside game.Workspace.

I can replace the game.Workspace parent and hear my actions. If I try and temporarily move (reparent) the sounds from there, that can be a resource constraint when multiple users need that effect at once. Maybe they each have a workspace where they can find it, but that is confusing as the common workspace has no way to tell which resource is “owned” by which player.

I’ve read people saying the sounds should go inside a part instead of the more general player model. Well, if we aren’t too fussed about which part, I am sure the HumanRootPart works. You will find the sounds for the default actions are already there.

I have this methods to test preloading of my effects:

local function getSound(owner: Player, soundName: string): Sound
    -- return game.Workspace[soundName]
    return owner.Character.HumanoidRootPart[soundName]
end

The proof being in the pudding, here is how I load my sound to test playback.

local function loadSound(owner: Player, name: string, id: number)
    local character = owner.Character
    if not character or not character.Parent then
        character = owner.CharacterAdded:Wait()
    end
    local sound = Instance.new("Sound", character.HumanoidRootPart)
    -- local sound = Instance.new("Sound", game.Workspace)
    sound.SoundId = "rbxassetid://"..tostring(id)
    sound.Name = name
    if not sound.IsLoaded then
       sound.Loaded:Wait()
    end
end

I could have duplicate methods but it is basically 1 line that needs changing to load to the workspace, which works, but doesn’t really make sense from an OOP/ownership perspective. Coming from that safe start I found I needed to take care to ensure that the character was loaded, requiring 4 more lines (shown above already):

    local character = owner.Character
    if not character or not character.Parent then
        character = owner.CharacterAdded:Wait()
    end

Initially I tried using just the player, which worked, in that there were no errors, but the sounds could never be heard, anywhere.

To trigger these for testing I use this Server script (the above are all run on the server, but I have modularized them already):

local function onPlayerAdded(player: Player)
    loadSound(player, "thud", 541909913)
    local sound = getSound(player, "thud")
    task.wait(5)
    sound:Play()
end
Players.PlayerAdded:Connect(onPlayerAdded)

That’s all I have. I hear a lot of hate for running things server side, but this is a totally valid solution and makes tidier code that runs to a common schedule.