Making Okeanskiy terrain generation server-side

  1. What do you want to achieve?
    I need help on making the terrain generation server-side.
  2. What is the issue?
    The terrain generation that I use in my game is a modified version of Okeanskiy’s Terrain Generation, where instead of trees, random trash generate. Where the “generate tree” function would run, instead, a random model from a folder would be picked, and then cloned onto the terrain. There is a problem, however, with the trash generation system.
    The trash on the terrain is generated differently for different players. I discovered this when I showcased the game to a friend of mine, weeks ago. This was confirmed when I tested it with two different players in my Studio:

    The left player sees an old billboard, chunks of corroded metal, and a neon cube.

    The right player sees stone bricks, a slate block, and a battered bench.
  3. What solutions have you tried so far?
    One option I did was through utilizing remote events. For the client-side generation:
game.StarterPlayer.StarterPlayerScripts.Terrain (LocalScript)
local Chunk = require(game.ReplicatedStorage.Chunk)
TerrainEvents = game.ReplicatedStorage.Terrain

local RENDER_DISTANCE = 12
local CHUNKS_LOADED_PER_TICK = 4
local CAMERA = game.Workspace.CurrentCamera

local centerPosX
local centerPosZ
local chunks = {}
local chunkCount = 0
local fastLoad = true

local function chunkWait()
	chunkCount = (chunkCount + 1) % CHUNKS_LOADED_PER_TICK
	if chunkCount == 0 and not fastLoad then
		wait()
	end
end

local function updateCenterPosFromCamera()
	local camPos = CAMERA.CFrame.Position

	centerPosX = math.floor(camPos.X / Chunk.WIDTH_SIZE_X)
	centerPosZ = math.floor(camPos.Z / Chunk.WIDTH_SIZE_Z)
end

local function doesChunkExist(x,z)
	for index, chunk in pairs(chunks) do
		if chunk.x == x and chunk.z == z then
			return true
		end
	end

	return false
end

local function isChunkOutOfRange(chunk)
	if math.abs(chunk.x - centerPosX) > RENDER_DISTANCE 
		or math.abs(chunk.z - centerPosZ) > RENDER_DISTANCE then
		return true
	end

	return false
end

local function makeChunks()
	for x = centerPosX - RENDER_DISTANCE, centerPosX + RENDER_DISTANCE do
		for z = centerPosZ - RENDER_DISTANCE, centerPosZ + RENDER_DISTANCE do
			if not doesChunkExist(x,z) then
				table.insert(chunks,Chunk.new(x,z))
				chunkWait()
			end
		end
	end
end

local function deleteChunks()
	local n = #chunks

	for i = 1, n do
		local chunk = chunks[i]

		if isChunkOutOfRange(chunk) then
			chunk:Destroy()
			chunkWait()

			chunks[i] = nil
		end
	end

	local j = 0
	for i = 1, n do
		if chunks[i] ~= nil then
			j += 1
			chunks[j] = chunks[i]
		end
	end

	for i = j+1, n do
		chunks[i] = nil
	end
end

while true do
	TerrainEvents.UpdateCenterPosFromCamera:FireServer()

	TerrainEvents.DeleteChunks:FireServer()

	TerrainEvents.MakeChunks:FireServer()
	
	fastLoad = false

	wait(0.5)
end

While true, the script will activate the RemoteEvents found in game.ReplicatedStorage.TerrainEvents. The followings server-side script will dictate what to do OnServerInvoke. It is nearly the same as the Client-side generation:

game.ServerScriptService.TerrainServer (Script)
local Chunk = require(game.ReplicatedStorage.Chunk)
TerrainEvents = game.ReplicatedStorage.Terrain

local RENDER_DISTANCE = 12
local CHUNKS_LOADED_PER_TICK = 4
local CAMERA = game.Workspace.CurrentCamera

local centerPosX
local centerPosZ
local chunks = {}
local chunkCount = 0
local fastLoad = true

local function chunkWait()
	chunkCount = (chunkCount + 1) % CHUNKS_LOADED_PER_TICK
	if chunkCount == 0 and not fastLoad then
		wait()
	end
end

local function updateCenterPosFromCamera()
	local camPos = CAMERA.CFrame.Position

	centerPosX = math.floor(camPos.X / Chunk.WIDTH_SIZE_X)
	centerPosZ = math.floor(camPos.Z / Chunk.WIDTH_SIZE_Z)
end

local function doesChunkExist(x,z)
	for index, chunk in pairs(chunks) do
		if chunk.x == x and chunk.z == z then
			return true
		end
	end

	return false
end

local function isChunkOutOfRange(chunk)
	if math.abs(chunk.x - centerPosX) > RENDER_DISTANCE 
		or math.abs(chunk.z - centerPosZ) > RENDER_DISTANCE then
		return true
	end

	return false
end

local function makeChunks()
	for x = centerPosX - RENDER_DISTANCE, centerPosX + RENDER_DISTANCE do
		for z = centerPosZ - RENDER_DISTANCE, centerPosZ + RENDER_DISTANCE do
			if not doesChunkExist(x,z) then
				table.insert(chunks,Chunk.new(x,z))
				chunkWait()
			end
		end
	end
end

local function deleteChunks()
	local n = #chunks

	for i = 1, n do
		local chunk = chunks[i]

		if isChunkOutOfRange(chunk) then
			chunk:Destroy()
			chunkWait()

			chunks[i] = nil
		end
	end

	local j = 0
	for i = 1, n do
		if chunks[i] ~= nil then
			j += 1
			chunks[j] = chunks[i]
		end
	end

	for i = j+1, n do
		chunks[i] = nil
	end
end

TerrainEvents.UpdateCenterPosFromCamera.OnServerEvent:Connect(updateCenterPosFromCamera)
TerrainEvents.DeleteChunks.OnServerEvent:Connect(deleteChunks)
TerrainEvents.MakeChunks.OnServerEvent:Connect(makeChunks)

When play-tested, the server-side terrain generation just… stops.


While taking a screenshot of the problem, an error occurred twice in the scripts:
Maximum event re-entrancy depth exceeded for BindableEvent.Event

Just by looking at these two scripts, you could see that I am doing something wrong. What am I doing wrong?

if the generation is different on all clients and you don’t want that…then make a seed system and all clients should have the same seed

1 Like

The terrain system already utilizes a seed system; the ModuleScript “Chunk” in ReplicatedStorage involves the seed, which is an IntValue in the Workspace. The seed is defined by a one-line script in the ServerScriptService:

game.Workspace.SEED.Value = math.random(1000000)
game.ReplicatedStorage.Chunk (ModuleScript)
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

It seems that the problem lies in local function addTrash()… each different client would generate a random number. How would I modify the function?

local SEED = game.Workspace.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 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

I’ve watched Okeanskiy’s vid, and with confidence I can say that generating it on the server would only add to the problems.
He adds a global seed value, which makes the generation same for every client in the server.

If you make it server-sided, that would spike ping a lot (even 1k+) and the server might even crash at the end because of the parts it needs to create.
Even if you come up with a solution to that, the chunk system would be really, really buggy.

I heavily suggest to sticking to just LocalScripts as he made it.

1 Like

I understand. Thanks for the advice. I’ll try to find a way to make the trash remain consistent to all clients.

1 Like

Im pretty sure he already gave the solution, just have all the clients use the same seed value and the terrain should be the same