fix: loot bag + beacon vanished on respawn under StreamingEnabled

Root cause: with StreamingEnabled (the new-place default), respawning away
from the death spot let Roblox evict the bag from the owner's client — the
bag looked deleted, and the local Destroying event fired, permanently killing
the beacon (server bag was fine all along).

- The bag is now a Model set PersistentPerPlayer for its owner
  (AddPersistentPlayer) — it never streams out of the owner's view.
- The beacon is position-driven plain data: no Instance in the remote, so no
  replication race (the 0.5s delay hack is gone) and no streaming fragility.
- The beam clears on an explicit server nil-fire when the bag is truly
  emptied/expired (plus a lifetime fallback), not on replica Destroying.

Live-verified (fresh bytecode confirmed via bag ClassName): beacon appears
instantly, survives respawn, bag stays visible to the owner, loot restores
everything, beam clears on empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Samuel Lison
2026-07-06 15:31:01 +10:00
co-authored by Claude Opus 4.8
parent 1b3eea714a
commit ee77ca9816
3 changed files with 70 additions and 29 deletions
+10
View File
@@ -5,6 +5,16 @@ All notable changes to SurvivorCore are recorded here. The format follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html). At release time, `## Unreleased`
is promoted to the new version and `main` is tagged `vX.Y.Z`.
## Unreleased
### Fixed
- **Loot bag + beacon vanished on respawn (StreamingEnabled places)** — respawning away from the
death spot let Roblox stream the bag out of the owner's client: the bag looked deleted and the
local `Destroying` fired, killing the beacon. The bag is now a **stream-persistent Model for its
owner** (`AddPersistentPlayer`), and the beacon is **position-driven plain data** — no Instance
reference, so no replication race (the 0.5s delay is gone) and no streaming fragility. The beam
clears on an explicit server signal when the bag is truly emptied/expired.
## 0.7.2 — 2026-07-06
### Fixed
+30 -22
View File
@@ -2,9 +2,12 @@
--[[
LootBagBeacon client. Owner-only "your stuff is over there" juice (TCE port).
When the server drops YOUR loot bag it fires `LootBagDropped` at you; this raises a tall golden
beam + a soft ground ring + a point light over the bag visible only to you (built locally).
Everything cleans itself up when the bag despawns or empties. Booted by startClient().
The server fires `LootBagDropped` at the owner with a POSITION + lifetime (plain data immune
to instance-replication races and streaming eviction); this raises a tall golden beam + ground
ring + light there, visible only to this player. It clears when the server signals the bag is
truly gone (a nil fire: looted empty or expired) or when the lifetime lapses NOT when the
bag's replica streams out, which is what used to kill the beacon on respawn. Booted by
startClient().
]]
local RunService = game:GetService("RunService")
@@ -19,18 +22,20 @@ local started = false
local GOLD = Color3.fromRGB(255, 220, 80)
local function bagHost(bag: Instance): BasePart?
if bag:IsA("BasePart") then
return bag
local currentBeam: BasePart? = nil
local currentToken = 0 -- invalidates stale lifetime timers when a new bag drops
local function clearBeacon()
currentToken += 1
if currentBeam and currentBeam.Parent then
currentBeam:Destroy()
end
return bag:FindFirstChildWhichIsA("BasePart")
currentBeam = nil
end
local function buildBeacon(bag: Instance)
local host = bagHost(bag)
if not host then
return
end
local function buildBeacon(position: Vector3, lifetime: number)
clearBeacon()
local token = currentToken
local beam = Instance.new("Part")
beam.Name = "_LootBeacon"
@@ -41,7 +46,7 @@ local function buildBeacon(bag: Instance)
beam.Color = GOLD
beam.Transparency = 0.35
beam.Size = Vector3.new(0.8, 120, 0.8)
beam.CFrame = CFrame.new(host.Position + Vector3.new(0, 60, 0))
beam.CFrame = CFrame.new(position + Vector3.new(0, 60, 0))
local ring = Instance.new("Part")
ring.Name = "_LootBeaconRing"
@@ -53,21 +58,22 @@ local function buildBeacon(bag: Instance)
ring.Color = GOLD
ring.Transparency = 0.55
ring.Size = Vector3.new(0.2, 5, 5)
ring.CFrame = CFrame.new(host.Position + Vector3.new(0, 0.2, 0)) * CFrame.Angles(0, 0, math.rad(90))
ring.CFrame = CFrame.new(position + Vector3.new(0, 0.2, 0)) * CFrame.Angles(0, 0, math.rad(90))
ring.Parent = beam
local light = Instance.new("PointLight")
light.Color = GOLD
light.Brightness = 2.5
light.Range = 18
light.Parent = host
light.Parent = beam
beam.Parent = workspace
currentBeam = beam
-- Clean up when the bag goes away (looted empty or despawned).
bag.Destroying:Connect(function()
if beam.Parent then
beam:Destroy()
-- Fallback cleanup at lifetime (the server's nil-fire normally clears it first).
task.delay(math.max(1, lifetime), function()
if token == currentToken and currentBeam == beam then
clearBeacon()
end
end)
end
@@ -78,9 +84,11 @@ function LootBagBeacon.start(_options: { [string]: any }?)
end
started = true
Remotes.event("LootBagDropped").OnClientEvent:Connect(function(bag, _lifetime)
if typeof(bag) == "Instance" then
buildBeacon(bag)
Remotes.event("LootBagDropped").OnClientEvent:Connect(function(position, lifetime)
if typeof(position) == "Vector3" then
buildBeacon(position, tonumber(lifetime) or 300)
else
clearBeacon() -- nil = the bag is truly gone (looted empty or expired)
end
end)
end
+30 -7
View File
@@ -198,9 +198,27 @@ local function dropBag(player: Player, position: Vector3)
return -- died empty-handed; no bag
end
-- The bag is a MODEL so it can be made stream-persistent for its owner: with the default
-- StreamingEnabled place, respawning far from the death spot would otherwise evict the bag
-- from the owner's client — it looked deleted (and killed the beacon) even though the server
-- still had it.
local tmpl = bagTemplate()
local bag: Instance = if tmpl then tmpl:Clone() else buildPlaceholderBag()
local inner: Instance = if tmpl then tmpl:Clone() else buildPlaceholderBag()
local bag: Model
if inner:IsA("Model") then
bag = inner
else
bag = Instance.new("Model")
inner.Parent = bag
if inner:IsA("BasePart") then
bag.PrimaryPart = inner
end
end
bag.Name = "LootBag"
pcall(function()
bag.ModelStreamingMode = Enum.ModelStreamingMode.PersistentPerPlayer
bag:AddPersistentPlayer(player)
end)
-- Aggregate contents as IntValue children (item id → qty), equipment included.
local counts: { [string]: number } = {}
@@ -268,13 +286,18 @@ local function dropBag(player: Player, position: Vector3)
Hooks.run("lootbag:dropped", ctx)
EventBridge.fire("lootbag:dropped", player, { position = position, items = total })
-- Owner-only client juice: beacon + "your things dropped" toast. The beacon fire is DELAYED a
-- beat — the bag was created this frame, and an Instance sent before it replicates arrives as
-- nil on the client (the beacon would silently never appear).
-- Owner-only client juice: beacon + "your things dropped" toast. The beacon payload is PLAIN
-- DATA (position + lifetime) — no Instance reference, so there is no replication race and no
-- streaming fragility; the client builds the beam at the position and keeps it until the bag
-- is truly gone (the nil-fire below) or the lifetime lapses.
local host2 = bagHost(bag)
local beaconPos = if host2 then host2.Position else position
if cfg.OwnerBeacon ~= false then
task.delay(0.5, function()
if bag.Parent and player.Parent then
Remotes.event("LootBagDropped"):FireClient(player, bag, lifetime)
Remotes.event("LootBagDropped"):FireClient(player, beaconPos, lifetime)
bag.Destroying:Connect(function()
-- Truly gone server-side (emptied or expired) → clear the owner's beacon early.
if player.Parent then
Remotes.event("LootBagDropped"):FireClient(player, nil)
end
end)
end