Articles › Game development

Roblox EP5 Lava Rising: build the level in code, test the rules outside Studio

The series' second game: a round game where lava rises and whoever reaches the top scores. The level is built from code with Rojo, and Lune checks every jump is possible.

Intermediate 10 min read October 20, 2026 Your first Roblox game · lesson 5/5

What you will learn
  • A level built from code changes everywhere when you change one number, and its history lives in Git
  • Rules written as functions that never touch Roblox objects can be tested for fairness outside Studio
  • Almost every round game has the same shape, wait → play → judge → wait again, and the server always judges

In EP1-4 we placed floors and steps by hand in Studio, then wrote code to make the parts behave. This episode builds a second game, Lava Rising, a round game you can play with friends, and changes the method: the whole level is built from code, and the game’s rules are tested outside Studio before anyone presses Play. That is how real teams work once a game starts to grow.

This one is a step up. If you skipped EP3 on the server and players’ devices, read it first.

The game

  1. Players spawn on a lobby island with a 15-second countdown
  2. Everyone is teleported around the foot of a spiral tower of 24 steps
  3. Five seconds later the lava starts to rise, in waves: slow, then a surge
  4. Feet in the lava means death and back to the lobby
  5. After 75 seconds, everyone standing on the golden summit gets Wins +1, and a new round starts

On the way up there are two special steps: blue ones that bob up and down, and brown ones that shake and crumble when you land.

Why build the level in code

Placed by hand in StudioBuilt from code
Make every step talleredit 24 parts one by onechange one number
Keep a history of changeshard: the place is one big filetext files, Git works
Check every jump is possibleplay it yourself every timewrite a test that calculates it
See the level while editingyesno, press Play
Hand-sculpted, artistic levelsa good fita poor fit

This game is a repeated shape from a formula, steps spiralling around a pillar at equal spacing, so code suits it well. A level that needs careful placement corner by corner is better done by hand. Many games mix both.

Two tools

  • Rojo connects code files on your computer with Studio. You write plain .luau files, and Rojo builds a .rbxlx place file that Studio opens
  • Lune runs Luau outside Roblox. We use it to run the rules tests on our own machine without opening Studio

Both are open source, downloaded from the Releases page of each project on GitHub. This episode uses Rojo 7.7.1 and Lune 0.11.0 on Windows.

Where each file lands in Studio:

FileIn StudioType
src/shared/Config.luauReplicatedStorage › SharedModuleScript, every number
src/shared/RoundLogic.luauReplicatedStorage › SharedModuleScript, the rules
src/server/MapBuilder.luauServerScriptService › ServerModuleScript, builds the level
src/server/Main.server.luauServerScriptService › ServerScript, the game loop
src/client/StatusUI.client.luauStarterPlayerScripts › ClientLocalScript, the status bar

Rojo reads the file endings: .server.luau becomes a Script, .client.luau a LocalScript, anything else a ModuleScript. default.project.json maps folders to places in Studio:

{
  "name": "lava-rising",
  "tree": {
    "$className": "DataModel",
    "ReplicatedStorage": { "Shared": { "$path": "src/shared" } },
    "ServerScriptService": { "Server": { "$path": "src/server" } },
    "StarterPlayer": {
      "StarterPlayerScripts": { "Client": { "$path": "src/client" } }
    }
  }
}

One command builds the place, then double-click the result to open it in Studio:

rojo build -o build/lava-rising.rbxlx

Every number in one file

Config.luau holds every number that shapes the game’s look and difficulty. An excerpt:

local Config = {
	INTERMISSION = 15,   -- seconds in the lobby
	ROUND_TIME = 75,     -- round length
	LAVA_DELAY = 5,      -- seconds before the lava moves

	TOWER_STEPS = 24,    -- number of steps
	STEP_RISE = 5,       -- height per step (a normal jump reaches about 7)
	STEP_ANGLE = 30,     -- degrees turned per step
	TOWER_RADIUS = 22,   -- distance from the pillar to the steps
	PLATFORM_SIZE = 8,

	SURGE_WAVES = 3,     -- the lava rises in waves, this many per round
	SURGE_STRENGTH = 0.6 -- 0 = steady, 0.6 = between 0.4x and 1.6x the average speed
}
return Config

Want it harder? Lower ROUND_TIME. A taller tower? Raise TOWER_STEPS. The whole level follows.

The rules as plain functions

The key idea of this episode is separating the game’s rules from Roblox objects. RoundLogic.luau takes numbers in and gives numbers back. It never touches a Part or a player. For example, where each step goes:

function RoundLogic.towerLayout(cfg)
	local points = {}
	for i = 1, cfg.TOWER_STEPS do
		local a = math.rad(i * cfg.STEP_ANGLE)
		table.insert(points, {
			x = cfg.TOWER_RADIUS * math.cos(a),
			y = i * cfg.STEP_RISE,
			z = cfg.TOWER_RADIUS * math.sin(a),
		})
	end
	return points
end

Step i turns i × 30 degrees and rises i × 5 studs: a spiral around the pillar. MapBuilder then turns these positions into real Parts.

The rule for who burns is short too. What we can measure is the HumanoidRootPart, which sits about 3 studs above the feet:

function RoundLogic.isBurned(rootY, lavaTop, cfg)
	return rootY - cfg.BURN_MARGIN <= lavaTop  -- BURN_MARGIN = 3
end
surgesurgesurgesummit 125moves at 5 stime (seconds)heightreal lava: in wavesif it rose steadily0255075
Only ever rising: three waves of slow and surge, stopping just under the summit as time runs out

The lava doesn’t rise in a straight line but in waves: slow stretches to breathe, surges to panic. The formula is built so the lava only ever goes up, and reaches its highest point exactly when time runs out, 4 studs under the summit, so whoever is on top always survives.

Testing the rules outside Studio

Because the rules never touch Roblox, Lune can run them. tests/round_logic.luau asks questions that calculation can answer:

check("a step is jumpable (rise under 7)", Config.STEP_RISE < 7)

local gap = RoundLogic.stepGap(Config)
check("gap between steps is positive and short (0-6 studs)", gap > 0 and gap < 6)

-- even at the fastest surge, there must be at least 1.5 seconds per step
local secondsPerStep = Config.STEP_RISE / RoundLogic.lavaPeakSpeed(Config)
check("lava gives at least 1.5 s per step, even in a surge", secondsPerStep >= 1.5)

check("standing on the summit at the end is safe", not RoundLogic.isBurned(summitTop + 3, endY, Config))

Run it on your machine:

lune run tests/round_logic
lava
  ok  still during the head start
  ok  lava only ever goes up
  ...
climb time
  ok  lava gives at least 1.5 s per step, even in a surge
...
34 passed, 0 failed

A worked example, the gap between steps: two step centres 30 degrees apart on a circle of radius 22 are 2 × 22 × sin 15° ≈ 11.4 studs apart. Take away the 8-stud step width and the real gap is ≈ 3.4 studs, an easy jump.

The real payoff: the day you change STEP_RISE to 8 for a taller tower, the tests tell you at once that the jump is impossible, before any player finds out.

Test what code can calculate. Save real play for what numbers can’t measure: is it fun?

The game loop: wait, play, judge

Lobby15 s countdownRoundteleport, lava 75 sJudgesurvivors Wins +1every frame: move lava, check burnsthe server does all of this
while true do: wait, play, judge, repeat

Main.server.luau is the one server Script that runs the whole game. Its shape:

while true do
	intermission()  -- countdown in the lobby
	playRound()     -- teleport, lava rises, judge
end

Three things in playRound are worth a look.

1. Track who is alive. At teleport, each player’s name goes into an alive table, and we listen for their Died:

alive[player.Name] = true
humanoid.Died:Connect(function()
	alive[player.Name] = false
end)

2. Move the lava and check every frame, with RunService.Heartbeat, which runs every frame on the server:

RunService.Heartbeat:Connect(function()
	lavaTop = RoundLogic.lavaHeight(os.clock() - started, Config)
	MapBuilder.setLava(lava, lavaTop)
	for _, player in Players:GetPlayers() do
		local root = if alive[player.Name] then rootOf(player) else nil
		if root and RoundLogic.isBurned(root.Position.Y, lavaTop, Config) then
			player.Character:FindFirstChildWhichIsA("Humanoid").Health = 0
		end
	end
end)

Why not Touched, like the lava in EP2? This lava block moves every frame and covers the whole arena. Comparing heights directly is more certain and easier to read.

The if alive[player.Name] line comes from a real bug caught before the first play: the lava climbs higher than the lobby island, so checking everyone in the server would burn people waiting in the lobby, nowhere near the lava.

3. Judge at the end. Everyone still in alive gets Wins +1 and goes back to the lobby. All of this happens on the server; players’ devices only show text, following the EP3 rule: never trust the client.

The status bar uses the simplest method there is: the server writes into a StringValue called Status in ReplicatedStorage, and the player’s LocalScript just reads and shows it. No RemoteEvent needed.

Making it more fun

The first version worked but felt flat: climb around and around. The second version added three things:

  • Lava in waves, three per round. During a surge the status bar turns red and reads LAVA SURGE!
  • Special steps in a pattern, normal › moving › normal › crumbling, repeating up the tower. Moving steps bob with TweenService; crumbling steps shake for 0.6 seconds, then vanish for 3
  • A height-above-lava meter on the player’s screen, and a red glow that grows as the lava gets close. Display only, on the player’s device; it decides nothing

Fun must not cost fairness, so the tests gained new rules, and all of them must pass before the place is built:

  • The first 3 steps are always normal, so everyone gets a calm start
  • Never two special steps in a row
  • The last step before the summit is normal
  • A moving step plus its bob is still under 7 studs high

Play it on your computer

  1. Build the place with rojo build, then double-click build/lava-rising.rbxlx
  2. Studio opens to an empty sky. That’s expected
  3. Press Play (the blue ▶ at the top left) or F5. Solo works, because MIN_PLAYERS = 1
  4. For several players: Test › Clients and Servers, set 2-3 players, Start
  5. Keep the Output window open from the Window menu to see errors

To open the file from inside Studio instead, use File › Open from File.

Common mistakes

  • Checking the lava against every player in the server, so people in the lobby burn too
  • Not disconnecting at the end of a round, so the next round runs two lava checkers. Keep every connection in a table and Disconnect them all at the end
  • Letting the lava reach the summit, so nobody can ever win. It must stop below the top
  • Changing a number in Config without running the tests, and shipping a jump nobody can make
  • A LocalScript in the wrong place, which, as EP3 explained, silently never runs

Next episode

Right now Wins vanish whenever you leave. EP6 saves player data between visits with DataStore, handles saves that fail, and publishes the game to Roblox, which DataStore needs first.

FAQ

Do I need Rojo, or can I do this in Studio alone?

Studio alone works. Every file in this episode can be pasted into a Script or ModuleScript in the place shown in the table. Rojo pays off as a project grows, because you write code in the editor you like, use Git, and let AI help edit the files.

Why do I only see an empty sky when I open the place?

The level is built when the server starts. Press Play and it appears. That is the trade-off of building levels in code.

What is Lune, and is it safe?

Lune runs the Luau language outside Roblox. It is open source and downloaded from the project's GitHub. Here it only runs the rules tests on your own machine; nothing is sent to Roblox.

Commands and settings were tested on sample systems. Try them on a test machine before production.