Making Okeanskiy trash generation consistent for all clients

“What do you want to achieve?”

My game uses a modified version of Okeanskiy’s Terrain Generation system, where:

  • The terrain colors range in the orange-yellow spectrum.
  • Instead of trees, garbage of random shapes, sizes, and material, spawn.

So far, this modified version of the terrain generation system should generate a landscape like this, not counting the custom sky-box:


Many destroyed and battered-up trash litter the dusty landscape of the Scrapyard realm. You might find some of the trash to be familiar objects, such as the enemy insects/arachnids you defeated in Bee Swarm Simulator, that one player’s plate that did not make it in Plates of Fate, or a prop from a long-gone game from your childhood, dating from the early months of ROBLOX.

However, there is a flaw in the modification: the trash is not consistent to all clients, as I stated last time. For example, to take the first notice of the problem, one player could be looking at a ruined spawn location, while another would perceive the same garbage as a mantis corpse.

What I want to achieve is the direct opposite of the flaw: trash that is consistent to all clients, so that if one player sees a wrecked tank, the other player perceives it as a wrecked tank, and nothing else.

“What is the issue?”

The problem actually lies in making the props consistent to all clients. SquarePapyrus12 suggested that I set up “a seed system and all clients should have the same seed”. However, my first few attempts on doing so ended up backfiring, returning a landscape devoid of any trash.

“What solutions have you tried so far?”

For reference, here is the original Chunk ModuleScript, complete with the modifications to add trash. (This very same ModuleScript was involved in generating the old landscape above.)

local TERRAIN_HEIGHT_COLORS	= {
	[-50] = Color3.fromRGB(169,88,33);	-- Dark orange-ish
	[-10] = Color3.fromRGB(197,140,69);	-- Orange-ish
	[0] = Color3.fromRGB(212,173,97);	-- Yellow orange-ish
	[75] = Color3.fromRGB(223,210,158)	-- Pale yellowish
}	
local X, Z = 4,4
local WIDTH_SCALE = 16
local HEIGHT_SCALE = 100
local TERRAIN_SMOOTHNESS = 20
local MIN_TRASH_SPAWN_HEIGHT = -15
local MAX_TRASH_SPAWN_HEIGHT = 30
local TRASH_DENSITY = 0.5				-- Number between 0 and 1
local SEED = game.Workspace.SEED.Value

local TerrainModel = game.Workspace.TerrainModel
local TrashModels = game.Workspace.TrashModels
local Terrain = game.Workspace.Terrain

local wedge = Instance.new("WedgePart")
wedge.Anchored = true
wedge.TopSurface = Enum.SurfaceType.Smooth
wedge.BottomSurface = Enum.SurfaceType.Smooth

local function draw3DTriangle(a, b, c)
	local ab,ac,bc = b-a, c-a, c-b
	local abd,acd,bcd = ab:Dot(ab), ac:Dot(ac), bc:Dot(bc)

	if (abd > acd) and (abd > bcd) then
		c,a = a,c
	elseif (acd > bcd) and (acd > abd) then
		a,b = b,a
	end

	ab,ac,bc = b-a, c-a, c-b

	local right = ac:Cross(ab).unit
	local up = bc:Cross(right).unit
	local back = bc.unit

	local height = math.abs(ab:Dot(up))

	local w1 = wedge:Clone()
	w1.Size = Vector3.new(0,height,math.abs(ab:Dot(back)))
	w1.CFrame = CFrame.fromMatrix((a+b)/2, right, up, back)
	w1.Parent = TerrainModel

	local w2 = wedge:Clone()
	w2.Size = Vector3.new(0,height,math.abs(ac:Dot(back)))
	w2.CFrame = CFrame.fromMatrix((a+c)/2, -(right), up, -(back))
	w2.Parent = TerrainModel

	return w1, w2
end

local function getHeight(chunkPosX, chunkPosZ, x, z)
	local height = math.noise(
		(X/TERRAIN_SMOOTHNESS * chunkPosX) + x/TERRAIN_SMOOTHNESS, 
		(Z/TERRAIN_SMOOTHNESS * chunkPosZ) + z/TERRAIN_SMOOTHNESS,
		SEED
	) * HEIGHT_SCALE
	
	if height > 20 then
		local difference = height - 20
		height += (difference * 1.2)
	end
	
	if height < -20 then
		local difference = height - -20
		height += (difference * 1.2)
	end
	
	return height
end


local function getPosition(chunkPosX, chunkPosZ, x, z)
	return Vector3.new(
		(chunkPosX*X*WIDTH_SCALE) + x*WIDTH_SCALE,
		getHeight(chunkPosX, chunkPosZ, x, z),
		(chunkPosZ*Z*WIDTH_SCALE) + z*WIDTH_SCALE
	)
end

local function paintWedge(wedge)
	local wedgeHeight = wedge.Position.Y
	
	local color
	local lowerColorHeight
	local higherColorHeight
	
	for height,heightColor in pairs(TERRAIN_HEIGHT_COLORS) do
		if wedgeHeight == height then
			color = heightColor
			break
		end
		
		if (wedgeHeight < height) and (not higherColorHeight or height < higherColorHeight) then
			higherColorHeight = height
		end
		
		if (wedgeHeight > height) and (not lowerColorHeight or height > lowerColorHeight) then
			lowerColorHeight = height
		end
	end
	
	if not color then
		if higherColorHeight == nil then
			color = TERRAIN_HEIGHT_COLORS[lowerColorHeight]
		elseif lowerColorHeight == nil then
			color = TERRAIN_HEIGHT_COLORS[higherColorHeight]
		else
			local alpha = (wedgeHeight - lowerColorHeight) / (higherColorHeight - lowerColorHeight)
			local lowerColor = TERRAIN_HEIGHT_COLORS[lowerColorHeight]
			local higherColor = TERRAIN_HEIGHT_COLORS[higherColorHeight]
			
			color = lowerColor:lerp(higherColor, alpha)
		end
	end
	
	wedge.Material = Enum.Material.Sand
	wedge.Color = color
end

local function addWater(chunk)
	local cframe = CFrame.new(
		(chunk.x + 0.5) * (chunk.WIDTH_SIZE_X),
		-70,
		(chunk.z + 0.5) * (chunk.WIDTH_SIZE_Z)
	)
	
	local size = Vector3.new(
		chunk.WIDTH_SIZE_X,
		100,
		chunk.WIDTH_SIZE_Z
	)
	
	game.Workspace.Terrain:FillBlock(cframe, size, Enum.Material.Water)
	
	chunk.waterCFrame = cframe
	chunk.waterSize = size
end

local function addTrash(chunk)
	local posGrid = chunk.positionGrid
	local instances = chunk.instances
	local chunkPosX = chunk.x
	local chunkPosZ = chunk.z
	
	for x = 0, X-1 do
		for z = 0, Z-1 do
			local pos = posGrid[x][z]
			
			if pos.Y >= MIN_TRASH_SPAWN_HEIGHT and pos.Y <= MAX_TRASH_SPAWN_HEIGHT then
				
				math.randomseed((x * (chunkPosX+SEED))+(z * (chunkPosZ+SEED)))
				if math.random() < TRASH_DENSITY then
					local garbage = game.ReplicatedStorage.Garbage:GetChildren()
					local trash = garbage[math.random(1,#garbage)]:Clone()
					
					local cframe = CFrame.new(pos)
						* CFrame.new(
							math.random()*math.random(-10,10),
							0,
							math.random()*math.random(-10,10)
						)
						* CFrame.Angles(
							math.rad(math.random()*math.random(-30,30)),
							2*math.pi*math.random(),
							math.rad(math.random()*math.random(-30,30))
						)
					
					trash:SetPrimaryPartCFrame(cframe)
					trash.Parent = TrashModels
					
					table.insert(instances, trash)
				end
				
			end
		end
	end
end

local Chunk = {}
Chunk.__index = Chunk

Chunk.WIDTH_SIZE_X = X * WIDTH_SCALE
Chunk.WIDTH_SIZE_Z = Z * WIDTH_SCALE

function Chunk.new(chunkPosX, chunkPosZ)
	local chunk = {
		instances = {};
		positionGrid = {};
		x = chunkPosX;
		z = chunkPosZ;
	}
	
	setmetatable(chunk,Chunk)
	
	local positionGrid = chunk.positionGrid
	
	for x = 0, X do
		positionGrid[x] = {}

		for z = 0, Z do
			positionGrid[x][z] = getPosition(chunkPosX,chunkPosZ,x,z)
		end
	end

	for x = 0, X-1 do
		for z = 0, Z-1 do
			local a = positionGrid[x][z]
			local b = positionGrid[x+1][z]
			local c = positionGrid[x][z+1]
			local d = positionGrid[x+1][z+1]

			local wedgeA, wedgeB = draw3DTriangle(a,b,c)
			local wedgeC, wedgeD = draw3DTriangle(b,c,d)
			
			paintWedge(wedgeA)
			paintWedge(wedgeB)
			paintWedge(wedgeC)
			paintWedge(wedgeD)
			
			table.insert(chunk.instances, wedgeA)
			table.insert(chunk.instances, wedgeB)
			table.insert(chunk.instances, wedgeC)
			table.insert(chunk.instances, wedgeD)
			--[[
			Terrain:FillBlock(wedgeA.CFrame,wedgeA.Size,Enum.Material.Sand)
			Terrain:FillBlock(wedgeB.CFrame,wedgeB.Size,Enum.Material.Sand)
			Terrain:FillBlock(wedgeC.CFrame,wedgeC.Size,Enum.Material.Sand)
			Terrain:FillBlock(wedgeD.CFrame,wedgeD.Size,Enum.Material.Sand)
			]]--
		end
	end
	
	addWater(chunk)
	addTrash(chunk)
	
	return chunk
end

function Chunk:Destroy()
	for index, instance in ipairs(self.instances) do
		instance:Destroy()
	end
	
	game.Workspace.Terrain:FillBlock(self.waterCFrame, self.waterSize, Enum.Material.Air)
end

return Chunk

One solution I tried was to modify the addTrash function so that it takes a NumberValue, “TRASH_SEED”, and increments it in hundredths for every time the block was run.

TRASH_SEED = game.Workspace.TRASH_SEED.Value

local function addTrash(chunk)
	local posGrid = chunk.positionGrid
	local instances = chunk.instances
	local chunkPosX = chunk.x
	local chunkPosZ = chunk.z
	
	for x = 0, X-1 do
		for z = 0, Z-1 do
			local pos = posGrid[x][z]
			
			if pos.Y >= MIN_TRASH_SPAWN_HEIGHT and pos.Y <= MAX_TRASH_SPAWN_HEIGHT then
				
				math.randomseed((x * (chunkPosX+SEED))+(z * (chunkPosZ+SEED)))
				if math.random() < TRASH_DENSITY then
					local p = math.noise(TRASH_SEED) + 0.25
					local garbage = game.ReplicatedStorage.Garbage:GetChildren()
					local q = (p/0.75) * #garbage
					local r = math.ceil(q+1)
					if r then
						continue
					else
						r = #garbage
					end
					local trash = garbage[r]:Clone()
					
					local cframe = CFrame.new(pos)
						* CFrame.new(
							math.random()*math.random(-10,10),
							0,
							math.random()*math.random(-10,10)
						)
						* CFrame.Angles(
							math.rad(math.random()*math.random(-30,30)),
							2*math.pi*math.random(),
							math.rad(math.random()*math.random(-30,30))
						)
					
					trash:SetPrimaryPartCFrame(cframe)
					trash.Parent = TrashModels
					
					table.insert(instances, trash)
					TRASH_SEED += 0.01
				end
			end
		end
	end
end

This modification instead caused the terrain generation to stop generating trash on the terrain; I must have done something wrong.

I tried setting the randomseed to the TRASH_SEED, but the trash still generated differently for each player.

if math.random() < TRASH_DENSITY then
	math.randomseed(TRASH_SEED)
	local garbage = game.ReplicatedStorage.Garbage:GetChildren()
	--	...
end

Another solution I tried was setting the network owner of each and every BasePart in the model:

for _,v in pairs(trash:GetDescendants()) do
	if v:IsA("BasePart") then
		v:SetNetworkOwner() 
	end
end

As you would expect, I got an error that "Network Ownership API can only be called from the Server."

It seems that these solutions I had tried only ended up in failure of generating client-consistent trash; either the trash is different for each client, or just does not generate.

What other better solutions can you provide?

Should you set the random seed once instead of setting the seed every time in the loop? This should only be set once.

Additionally using the random object is more preferable:

Not sure if it helps, but you could try to implement caching. When some client generates a chunk filled with garbage for the first time ever (so the player has been there before any other player) you just use a remote event to tell the server the positions/cframes of randomly generated garbage/trash/whatever-are-your-props. Next time someone comes to that chunk, you just check if someone has already been there by checking your server-side cache table (dictionary with key-value pairs, where keys could be prop/trash/garbage positions or cframes), and instead of generating trash again from a scratch, you just retrieve their positions/cframes from the server.

1 Like

Hmmm… That is actually a good idea. I’ll find a way to cache the data. Where can I start?

The idea is simple: just have a server-side table with keys as trash cframes/positions and values being trash models or unions or parts (whatever you are using)…

2 Likes