Stage 3: Plank Walkway + low gravity
Make sure you've finished Stage 2: Sphere Staircase + jump pads & comfort. Your VRController LocalScript should hold the climb code (Stage 1) and the comfort block (Stage 2), and a clean Play should show no errors.
a narrow plank walkway over a gap, crossed under low gravity
that gravity is just a number you can change, and how to warn a VR player with touch
a floaty balance challenge whose controllers buzz when you step off the path
The big idea
Here's a secret that feels like a cheat code: gravity in Roblox is just a number. It lives at workspace.Gravity, normally 196.2. Turn it down and everything falls slowly — jumps float, mistakes are gentler, the whole world feels like the moon.
That's perfect for a plank walkway — a thin path over a drop. Normal gravity makes a narrow beam terrifying. Low gravity turns it into a slow, floaty balance challenge where a wobble gives you time to recover. You'll build the planks, then build two pads: one that turns gravity down as you step onto the walkway, and one that turns it back up when you reach the far side.
The VR half is about your other senses. On a screen you can see exactly where the plank ends. In a headset, looking down at a thin beam is disorienting. So we add haptics — a buzz in your controllers the instant you step off solid footing. Your hands feel the danger before your eyes sort it out. That's a real VR design idea: when sight is unreliable, talk to touch.
- workspace.Gravity
- the global pull-down strength for the whole game; default 196.2
- raycast
- shooting an invisible line from a point in a direction to see what it hits first
- RaycastParams
- the settings for a raycast — like which objects to ignore (your own character)
- HapticService
- the service that vibrates controllers; how a VR game speaks to your hands
- VibrationMotor
- which motor to buzz — `LeftHand` and `RightHand` are the VR controllers
Build it
Step 1 — Build the plank walkway
Thin planks over a gap. Keep them narrow — the challenge is the width.
Build this partPlank_1
BlockOpen recipe
Plank_1
Block- Size
- 3 × 1 × 10
- Color
- Brown
- Material
- Wood
- Anchored
- ✓ Yes
- Place
- Just past the Stage 3 yellow pad, with a drop on both sides
The first Size number (3) is the width — keep it small. Length (10) runs along the path.
Build this partPlank_2
BlockOpen recipe
Plank_2
Block- Size
- 3 × 1 × 10
- Color
- Brown
- Material
- Wood
- Anchored
- ✓ Yes
- Place
- A small gap past Plank_1, in line with it
Build this partPlank_3
BlockOpen recipe
Plank_3
Block- Size
- 3 × 1 × 10
- Color
- Brown
- Material
- Wood
- Anchored
- ✓ Yes
- Place
- A small gap past Plank_2 — try offsetting it slightly left or right
Build this partPlank_4
BlockOpen recipe
Plank_4
Block- Size
- 3 × 1 × 10
- Color
- Brown
- Material
- Wood
- Anchored
- ✓ Yes
- Place
- The final plank, leading to where the next checkpoint will sit
Press ▶ Play and walk across at normal gravity. Notice how tense a thin plank feels. Now we make it floaty.
Step 2 — Build the low-gravity pads
Two pads. The first turns gravity down; the second turns it back up. You'll write both scripts from scratch.
2.1 The "gravity down" pad
Build this partGravityDownPad
BlockOpen recipe
GravityDownPad
Block- Size
- 6 × 1 × 6
- Color
- Cyan
- Material
- Neon
- Anchored
- ✓ Yes
- Place
- On solid ground right before Plank_1
Right-click GravityDownPad → Insert Object → Script (a server Script — gravity is the same for the whole world). Type:
local pad = script.Parent
local LOW_GRAVITY = 50 -- normal is 196.2; lower = floatier
pad.Touched:Connect(function(hit)
local humanoid = hit.Parent and hit.Parent:FindFirstChildOfClass("Humanoid")
if humanoid then
workspace.Gravity = LOW_GRAVITY
end
end)
2.2 The "gravity up" pad
Build this partGravityUpPad
BlockOpen recipe
GravityUpPad
Block- Size
- 6 × 1 × 6
- Color
- Bright orange
- Material
- Neon
- Anchored
- ✓ Yes
- Place
- On solid ground right after Plank_4
Insert a Script into GravityUpPad and type:
local pad = script.Parent
local NORMAL_GRAVITY = 196.2
pad.Touched:Connect(function(hit)
local humanoid = hit.Parent and hit.Parent:FindFirstChildOfClass("Humanoid")
if humanoid then
workspace.Gravity = NORMAL_GRAVITY
end
end)
Press ▶ Play. Step on the cyan pad — your next jump floats. Cross the planks slowly, then touch the orange pad to drop gravity back to normal. Tune LOW_GRAVITY until the float feels fun but still controllable. This whole mechanic is fully testable today, no headset needed.
Step 3 — Place the Stage 4 checkpoint
Same pattern as before:
- Insert a SpawnLocation just past the orange pad. Size
[6, 1, 6], anchored, a new color (try Lime green). Check AllowTeamChangeOnTouch, uncheck Neutral, set TeamColor to match. - Add a
StageNumberattribute (number) = 4. - Add a Team named Stage 4, matching TeamColor, AutoAssignable unchecked.
Play, cross the floaty planks, touch the new pad, reset — you should respawn there.
Step 4 — Add the VR edge-warning buzz
Open VRController. First, make sure HapticService is required at the top of the file (add it if it isn't there yet):
local HapticService = game:GetService("HapticService")
Then paste this block at the bottom of VRController. It shoots a ray straight down each frame; if there's no plank under your feet, it buzzes both controllers.
-- ===== VR edge warning (added in Stage 3) =====
local FALL_CHECK = 12 -- studs of ground we expect beneath us
local warningOn = false
local function setBuzz(strength)
HapticService:SetMotor(Enum.UserInputType.Gamepad1, Enum.VibrationMotor.LeftHand, strength)
HapticService:SetMotor(Enum.UserInputType.Gamepad1, Enum.VibrationMotor.RightHand, strength)
end
RunService.RenderStepped:Connect(function()
if not VRService.VREnabled then return end
local character = player.Character
local root = character and character:FindFirstChild("HumanoidRootPart")
if not root then return end
-- A ray pointing straight down, ignoring our own body.
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = { character }
local result = workspace:Raycast(root.Position, Vector3.new(0, -FALL_CHECK, 0), params)
if not result and not warningOn then
warningOn = true -- nothing solid below: we stepped off
setBuzz(0.4)
elseif result and warningOn then
warningOn = false -- back on a plank: stop buzzing
setBuzz(0)
end
end)
Press ▶ Play on your laptop. Output stays clean and nothing buzzes — there's no headset to vibrate, and VREnabled is false. That clean run is today's pass. The buzz gets its first real test at the Stage 10 playtest.
Crossing the planks in a headset, the moment a foot drifts off the edge into open air, both controllers give a soft warning buzz — you feel "danger" in your hands before your eyes finish reading the drop. Step back onto wood and the buzz stops. It turns a scary look-down into a calm, guided balance.
How the edge warning feels the floor with a ray
This is the same RenderStepped rhythm as the climb — check VR, read the world, react — but instead of moving the player it talks to their hands. It's a second connection in VRController, living happily beside the climb loop.
local FALL_CHECK = 12
local warningOn = false
local function setBuzz(strength)
HapticService:SetMotor(Enum.UserInputType.Gamepad1, Enum.VibrationMotor.LeftHand, strength)
HapticService:SetMotor(Enum.UserInputType.Gamepad1, Enum.VibrationMotor.RightHand, strength)
end
RunService.RenderStepped:Connect(function()
if not VRService.VREnabled then return end
local character = player.Character
local root = character and character:FindFirstChild("HumanoidRootPart")
if not root then return end
local params = RaycastParams.new()
params.FilterType = Enum.RaycastFilterType.Exclude
params.FilterDescendantsInstances = { character }
local result = workspace:Raycast(root.Position, Vector3.new(0, -FALL_CHECK, 0), params)
if not result and not warningOn then
warningOn = true
setBuzz(0.4)
elseif result and warningOn then
warningOn = false
setBuzz(0)
end
end)
Lines 1–2How far down we look, and whether we're already warning.
FALL_CHECK is how much solid ground we expect under our feet. warningOn remembers if the buzz is currently on, so we change the motors only when the situation flips — not 60 times a second.
Lines 4–7One helper to buzz both hands.
setBuzz takes a strength from 0 to 1 and sends it to both VR controllers. Wrapping the two SetMotor calls in one function keeps the loop below clean and means 'stop buzzing' is just setBuzz(0).
Lines 16–18Aim the ray and ignore yourself.
RaycastParams with FilterType Exclude tells the ray to skip our own character, otherwise it would hit our legs and always think there's ground. FilterDescendantsInstances is the list of things to ignore.
Line 20Shoot the ray straight down.
workspace:Raycast starts at the body, goes 12 studs down, and returns what it hits — or nil if it hits nothing. Over a plank you get a result; over the gap you get nil.
Lines 22–27Buzz on the way off, silence on the way back.
If the ray found nothing and we weren't already warning, start the buzz. If it found ground and we were warning, stop it. Comparing 'now' to warningOn is what keeps the motors from being spammed every frame.
Understand it
Low gravity works because workspace.Gravity is a single global number Roblox uses for every falling thing. Turning it down doesn't change your jump strength — it changes how slowly you come back down, so the same hop carries you much farther and gives you time to correct a wobble. We used two pads instead of one toggle because two one-way triggers are dead simple and reliable: enter → down, exit → up, no tricky "am I still inside?" bookkeeping. The trade-off is honest — because Gravity is global, in a real multiplayer game one player's low-gravity zone would affect everyone. (Fixing that per-player is the hard stretch.)
The edge warning shows what a raycast is really for: asking "what's directly over there?" We shoot a short ray down and read whether anything solid answers. The warningOn flag is the same trick as the jump pad's debounce and the climb's lastHandY — we act on the change, not the constant state. Buzzing on every frame would drain the controllers and feel like a constant rumble; buzzing only when you cross the edge makes it meaningful.
Both halves share one lesson: good feedback is about timing. The float gives you time; the buzz arrives exactly when you need it.
Try this
Try this
Three short experiments. Predict before you run, then test your guess.
LOW_GRAVITY is 50. Predict how the walkway feels at 10 (almost no gravity) versus 150 (just a little floaty). One makes the planks too easy and one barely helps — decide which before testing, then pick the value that's fun but still a challenge.
Cross the planks once with the edge-warning idea in mind, then imagine the buzz firing. Would a warning that buzzed harder the closer you got to the edge feel better than the on/off buzz you built? What would you have to measure to do that? (Hint: not just "is there ground," but "how close to the edge.")
You raycast straight down to feel the floor. Stage 7 has boulders rolling at you. What direction would you raycast to feel a boulder coming — and how could a buzz warn the player to duck? Jot the idea down; you'll want it later.
Test your stage
- Press ▶ Play and walk across the planks at normal gravity — feel how tense it is.
- Step on the cyan pad; your next jump should clearly float.
- Cross the planks under low gravity, then touch the orange pad — gravity returns to normal.
- Touch the Stage 4 pad, reset, and confirm you respawn there.
- Output is empty on a clean Play — no errors from either gravity Script or
VRController. - Design check. Do the cyan and orange pads read as "something changes here"? In VR, color and Neon are how players know a pad matters. If they blend into the path, brighten them.
If it breaks
- Gravity never goes back to normal. You touched the cyan pad but not the orange one — or the orange pad's Script has the wrong number. Confirm
NORMAL_GRAVITY = 196.2and that the orange pad is positioned where the player actually walks over it. - Gravity is stuck low after I fall and respawn. Known limitation of the two-pad design — respawning skips the orange pad. For a camp build it's fine; just step on the orange pad. (The hard stretch's per-player version avoids this.)
- The float is uncontrollable. Raise
LOW_GRAVITYtoward 100. Too-low gravity makes the planks frustrating, not fun. HapticServiceis underlined red in VRController. You didn't addlocal HapticService = game:GetService("HapticService")at the top of the file.- Nothing buzzes on my laptop. Expected — no headset means
VREnabledis false and the whole loop is skipped. The buzz is verified at the Stage 10 playtest.
The "gravity is just a number" reveal is the hook of this stage — say it out loud and let a camper change LOW_GRAVITY live and watch the world react. It's one of the most empowering moments in the course.
Two common snags: campers put the gravity Scripts in VRController (they're server Scripts, one inside each pad), and campers forget the orange pad, leaving the whole game floaty. Walk the room after Step 2 and confirm each pad has its own Script and the right gravity number.
As always, the haptic buzz can't be felt today; the clean Play is the pass. Reinforce that the raycast edge-warning is the same RenderStepped pattern as Stage 1 — they're not learning a new structure, just pointing it at a new problem.