Gaps in-between infinitely spawning sections

I am making a game where players are on an infinite train ride, and the goal is that there are Landscape/Terrain Sections that move back past the train to create the illusion of the train moving, then after a certain point delete the landscape sections and add more in front to create a sense that the train is infinitely moving forward. Train doesn’t move, landscape does.

Mostly everything is working, but for some reason the landscape sections spawn in gaps after a point, like there’s gaps in-between each section, about the size one of those sections. In my script I have a buffer that pre-spawns landscape sections, and those spawn just fine, but not the ones after.

Screenshot of problem:


the game is a bit foggy, sorry but on the right there are landscape sections that are evenly spaced but after a couple they spawn in gaps, like on the left side.

Heres the script I have for the game:

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")
local RunService = game:GetService("RunService")

-- Constants
local LANDSCAPE_LENGTH = 2048
local SPEED = 150
local SPAWN_BUFFER = 3
local DESPAWN_DISTANCE = (SPAWN_BUFFER + 4) * LANDSCAPE_LENGTH
local RARE_CHANCE = 0.125

local landscapesFolder = ReplicatedStorage:WaitForChild("Landscapes")
local commonFolder = landscapesFolder:WaitForChild("Common")
local rareFolder = landscapesFolder:WaitForChild("Rare")

local activeSections = Instance.new("Folder")
activeSections.Name = "ActiveSections"
activeSections.Parent = Workspace

local trainZ = 0
local sectionPool = {}
local spawnedSections = {}
local lastSpawnedIndex = -1

local function createKillZone()
	local part = Instance.new("Part")
	part.Name = "KillZone"
	part.Anchored = true
	part.CanCollide = false
	part.Transparency = 1
	part.Size = Vector3.new(LANDSCAPE_LENGTH, 16, LANDSCAPE_LENGTH)
	part.Position = Vector3.new(0, 0, 0)
	return part
end

local function setupKillZone(killZone)
	killZone.Touched:Connect(function(hit)
		local character = hit:FindFirstAncestorOfClass("Model")
		local humanoid = character and character:FindFirstChildOfClass("Humanoid")
		if humanoid and humanoid.Health > 0 and not character:FindFirstChild("FellOffTrain") then
			local tag = Instance.new("BoolValue")
			tag.Name = "FellOffTrain"
			tag.Parent = character

			local conn
			conn = RunService.Heartbeat:Connect(function(dt)
				if not character or not character:IsDescendantOf(Workspace) then
					conn:Disconnect()
					return
				end

				local root = character.PrimaryPart or character:FindFirstChild("HumanoidRootPart")
				if root then
					local moveVector = Vector3.new(0, 0, -SPEED * dt)
					local newCFrame = CFrame.new(root.Position + moveVector, root.Position + moveVector + root.CFrame.LookVector)
					character:SetPrimaryPartCFrame(newCFrame)
				else
					conn:Disconnect()
				end
			end)

			task.delay(0.001, function()
				if humanoid and humanoid.Health > 0 then
					humanoid.Health = 0
				end
			end)
		end
	end)
end

local function getRandomFromFolder(folder)
	local items = folder:GetChildren()
	if #items == 0 then return nil end
	return items[math.random(1, #items)]:Clone()
end

local function chooseLandscape()
	if math.random() < RARE_CHANCE then
		return getRandomFromFolder(rareFolder)
	else
		return getRandomFromFolder(commonFolder)
	end
end

local function prepareSection(section)
	section.ModelStreamingMode = Enum.ModelStreamingMode.Atomic
	for _, part in ipairs(section:GetDescendants()) do
		if part:IsA("BasePart") then
			part.Anchored = true
		end
	end
end

local function spawnSection(index)
	
	local zPos = index * LANDSCAPE_LENGTH - LANDSCAPE_LENGTH

	local section
	if #sectionPool > 0 then
		section = table.remove(sectionPool)
	else
		section = chooseLandscape()
		if not section then return end
		prepareSection(section)
	end

	local killZone = createKillZone()
	killZone.CFrame = CFrame.new(0, 1, zPos)
	killZone.Parent = activeSections
	setupKillZone(killZone)

	section:PivotTo(CFrame.new(0, 0, zPos))
	section.Parent = activeSections

	spawnedSections[index] = {
		section = section,
		killZone = killZone,
	}
end

-- Initial spawn
for i = 0, SPAWN_BUFFER + 1 do
	spawnSection(i)
	lastSpawnedIndex = i
end

RunService.Heartbeat:Connect(function(dt)
	local moveDist = SPEED * dt
	trainZ = trainZ + moveDist

	for index, data in pairs(spawnedSections) do
		local section = data.section
		local killZone = data.killZone

		if section and section:IsA("Model") then
			local sectionCFrame = section:GetPivot()
			local killZoneCFrame = killZone.CFrame

			local newSectionCFrame = sectionCFrame * CFrame.new(0, 0, -moveDist)
			section:PivotTo(newSectionCFrame)

			local newKillZoneCFrame = killZoneCFrame * CFrame.new(0, 0, -moveDist)
			killZone.CFrame = newKillZoneCFrame

			local frontEdgeZ = newSectionCFrame.Position.Z + (LANDSCAPE_LENGTH / 2)
			if frontEdgeZ < trainZ - DESPAWN_DISTANCE then
				section.Parent = nil
				killZone.Parent = nil
				spawnedSections[index] = nil
				table.insert(sectionPool, section)
			end
		end
	end

	local maxSpawnIndex = math.floor((trainZ + (SPAWN_BUFFER + 1) * LANDSCAPE_LENGTH) / LANDSCAPE_LENGTH)
	while lastSpawnedIndex < maxSpawnIndex do
		lastSpawnedIndex = lastSpawnedIndex + 1
		spawnSection(lastSpawnedIndex)
	end
end)

I have it set up to where the models of the sections are in replicated storage, and when they need to spawn in the script copies them out into the workspace, and after a point it gets deleted. If someone could help me solve the problem of the gaps, that would be much appreciated.

Maybe instead of using a fixed LANDSCAPE_LENGTH property to get the z-offset of these sections, consider using GetBoundingBox or GetExtentsSize to get the proper bounding size of the models. Then you can support variable-width sections, and they will always be flush.

In general, you should try to minimize your logic. This system just requires you to move all map parts backwards, and spawn new parts in front of old ones with a zone check

1 Like