diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs
new file mode 100644
index 0000000..1e7702b
--- /dev/null
+++ b/src/AcDream.App/Streaming/GpuWorldState.cs
@@ -0,0 +1,75 @@
+using System.Collections.Generic;
+using System.Linq;
+using AcDream.Core.World;
+
+namespace AcDream.App.Streaming;
+
+///
+/// Render-thread-owned registry of currently-loaded landblocks and their
+/// entities. All mutation happens in
+/// on the render thread; the renderer reads once per
+/// frame.
+///
+///
+/// Replaces GameWindow._entities, which was a flat list updated in
+/// multiple places. This class is the single point of truth for "what's in
+/// the world right now" and the only thing that mutates it.
+///
+///
+///
+/// Threading: not thread-safe. All calls must happen on the render thread.
+/// The streaming worker never touches this type.
+///
+///
+public sealed class GpuWorldState
+{
+ private readonly Dictionary _loaded = new();
+
+ // Cached flat view over all entities across all loaded landblocks,
+ // rebuilt on each add/remove. The renderer holds a reference to this
+ // list, so rebuilding it replaces the reference atomically.
+ private IReadOnlyList _flatEntities = System.Array.Empty();
+
+ public IReadOnlyList Entities => _flatEntities;
+ public IReadOnlyCollection LoadedLandblockIds => _loaded.Keys;
+
+ public bool IsLoaded(uint landblockId) => _loaded.ContainsKey(landblockId);
+
+ public void AddLandblock(LoadedLandblock landblock)
+ {
+ _loaded[landblock.LandblockId] = landblock;
+ RebuildFlatView();
+ }
+
+ public void RemoveLandblock(uint landblockId)
+ {
+ if (_loaded.Remove(landblockId))
+ RebuildFlatView();
+ }
+
+ ///
+ /// Append an entity to a specific landblock's slot. Used by the live
+ /// CreateObject path where the server spawns entities into an already-
+ /// loaded landblock after the initial hydration pass.
+ ///
+ public void AppendLiveEntity(uint landblockId, WorldEntity entity)
+ {
+ if (!_loaded.TryGetValue(landblockId, out var lb))
+ return;
+
+ // LoadedLandblock.Entities is an IReadOnlyList. Rebuild the
+ // landblock record with the new entity appended. We accept the
+ // allocation here because live spawns are rare compared to frame
+ // iteration.
+ var newEntities = new List(lb.Entities.Count + 1);
+ newEntities.AddRange(lb.Entities);
+ newEntities.Add(entity);
+ _loaded[landblockId] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, newEntities);
+ RebuildFlatView();
+ }
+
+ private void RebuildFlatView()
+ {
+ _flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray();
+ }
+}