Bridge Academy.
SUMMER CAMP OPTION · LEVEL I AND LEVEL II

Roblox Game Design

Roblox Game Design is an optional summer camp program for Level I and Level II students. Students build small game features in Roblox Studio by following a clear workflow, then copying tested scripts into the correct place.

FEATURE LIBRARY

Organized by game feature

Each feature starts with the Studio setup steps, then gives students one complete script to copy. The goal is to help students build working behavior first, while gradually connecting the script to familiar programming ideas like variables, conditions, loops, and functions.

READY FEATURE

Respawn an Object

Use this when a player drives, rides, or moves an object away from its starting place. When no matching object is within 25 studs of home, Roblox waits 5 seconds and spawns a fresh copy at the original location. The object that was driven away is left alone.

WORKFLOW FIRST

  1. Put the object in Workspace exactly where it should start.
  2. Duplicate that object and move the duplicate into ServerStorage.
  3. Make sure both objects have the exact same name.
  4. Open ServerScriptService, add a new Script, and paste the code below.
  5. Change only OBJECT_NAME, then press Play to test.

WHY IT WORKS

The script first looks for common moving parts, like a DriveSeat, VehicleSeat, Seat, or HumanoidRootPart. It checks whether any copy with the same name is still near the start, then clones the backup copy from ServerStorage only when the home spot is empty.

To respawn three different cars, make one script for each car and change OBJECT_NAME in each script.

COMPLETE SCRIPT
Paste into ServerScriptService
-- Put this Script in ServerScriptService.
-- Put a backup copy of the object in ServerStorage.
-- Roblox uses -- for comments. Python uses # for comments.

local ServerStorage = game:GetService("ServerStorage")

-- PART 1: Student settings
-- STUDENT CHANGE: Replace MyObject with the exact name of your car, plane, horse, or model.
local OBJECT_NAME = "MyObject"

-- STUDENT CHANGE: Try 2 for a faster respawn, or 10 for a slower respawn.
local RESPAWN_SECONDS = 5

-- STUDENT CHANGE:
-- Try a smaller number like 10 to respawn sooner.
-- Try a bigger number like 50 if the object is large.
local HOME_RADIUS = 25

-- PART 2: Good parts to track on moving objects
-- You usually do not need to change this list.
local TRACKING_PART_NAMES = {
    "DriveSeat",
    "DriverSeat",
    "HumanoidRootPart",
    "RootPart",
    "Torso",
}

-- PART 3: Find the backup object and the starting object
local templateObject = ServerStorage:FindFirstChild(OBJECT_NAME)

if not templateObject then
    warn("Missing backup object in ServerStorage: " .. OBJECT_NAME)
    return
end

local startingObject = workspace:FindFirstChild(OBJECT_NAME, true)

if not startingObject then
    warn("Missing starting object in Workspace: " .. OBJECT_NAME)
    return
end

local startCFrame

if startingObject:IsA("Model") then
    startCFrame = startingObject:GetPivot()
elseif startingObject:IsA("BasePart") then
    startCFrame = startingObject.CFrame
else
    warn(OBJECT_NAME .. " must be a Model or a Part")
    return
end

-- PART 4: Pick one part that tells us where the object is
-- Cars usually use a seat. Characters and animals often use HumanoidRootPart or Torso.
local function findTrackingPart(object)
    if object:IsA("BasePart") then
        return object
    end

    if not object:IsA("Model") then
        return nil
    end

    for _, partName in ipairs(TRACKING_PART_NAMES) do
        local part = object:FindFirstChild(partName, true)

        if part and part:IsA("BasePart") then
            return part
        end
    end

    local vehicleSeat = object:FindFirstChildWhichIsA("VehicleSeat", true)
    if vehicleSeat then
        return vehicleSeat
    end

    local seat = object:FindFirstChildWhichIsA("Seat", true)
    if seat then
        return seat
    end

    return object:FindFirstChildWhichIsA("BasePart", true)
end

local startPart = findTrackingPart(startingObject)

if not startPart then
    warn("Could not find a moving part inside: " .. OBJECT_NAME)
    return
end

local startPosition = startPart.Position
local isRespawning = false

-- PART 5: Check whether any copy of this object is still near home
local function objectIsAtStart()
    for _, thing in ipairs(workspace:GetDescendants()) do
        if thing.Name == OBJECT_NAME then
            local trackingPart = findTrackingPart(thing)

            if trackingPart then
                local distance = (trackingPart.Position - startPosition).Magnitude

                if distance < HOME_RADIUS then
                    return true
                end
            end
        end
    end

    return false
end

-- PART 6: Spawn a fresh copy at the starting place
-- This does NOT delete the object that was driven away.
local function spawnObject()
    local newObject = templateObject:Clone()

    if newObject:IsA("Model") then
        newObject:PivotTo(startCFrame)
    elseif newObject:IsA("BasePart") then
        newObject.CFrame = startCFrame
    end

    newObject.Parent = workspace
    print("Respawned: " .. OBJECT_NAME)
end

print("Watching object for respawn: " .. OBJECT_NAME)

-- PART 7: Main loop
-- Every second, check whether the home spot is empty.
while true do
    task.wait(1)

    if not isRespawning and not objectIsAtStart() then
        isRespawning = true
        task.wait(RESPAWN_SECONDS)

        if not objectIsAtStart() then
            spawnObject()
        end

        isRespawning = false
    end
end

READY FEATURE

Race Checkpoints

Use this when students build a simple race path. Players must touch Checkpoint1 through Checkpoint10 in order. Correct checkpoints show a green message, play a sound, and flash. Wrong checkpoints show a red message and tell the player which checkpoint to find next.

WORKFLOW FIRST

  1. Create a Folder in Workspace named RaceCheckpoints.
  2. Add 10 Parts inside the folder named Checkpoint1 through Checkpoint10.
  3. Place the Parts around the map in the order racers should drive through them.
  4. Make each checkpoint large enough to touch, then keep it transparent and non-colliding.
  5. Open ServerScriptService, add a new Script, and paste the code below.

WHY IT WORKS

The script gives each player their own next checkpoint number, so multiple students can race at the same time. It only counts a checkpoint if it is the exact next one in sequence.

It also creates floating number labels, screen messages, sounds, and checkpoint flashes from the script, so students do not need to build those effects by hand.

COMPLETE SCRIPT
Paste into ServerScriptService
local Players = game:GetService("Players")

-- PART 1: Student settings
-- STUDENT CHANGE: This folder must exist in Workspace.
local CHECKPOINT_FOLDER_NAME = "RaceCheckpoints"

-- STUDENT CHANGE: Change this if you want 5 checkpoints, 20 checkpoints, etc.
local CHECKPOINT_COUNT = 10

-- STUDENT CHANGE: After someone wins, wait this many seconds before resetting.
local RESET_AFTER_WIN_SECONDS = 5

-- STUDENT CHANGE: Sound played when the correct checkpoint is touched.
local CORRECT_SOUND_ID = "rbxassetid://12221967"

-- STUDENT CHANGE: Sound played when the wrong checkpoint is touched.
local WRONG_SOUND_ID = "rbxassetid://138090596"

-- STUDENT CHANGE: Sound played when the player wins.
local WIN_SOUND_ID = "rbxassetid://12222253"

-- STUDENT CHANGE: Try 0.3 for quiet, 1 for normal, or 2 for loud.
local SOUND_VOLUME = 1

-- STUDENT CHANGE: Change these colors to change the flash effect.
local CORRECT_FLASH_COLOR = Color3.fromRGB(80, 255, 120)
local WRONG_FLASH_COLOR = Color3.fromRGB(255, 80, 80)
local WIN_FLASH_COLOR = Color3.fromRGB(255, 215, 0)

-- STUDENT CHANGE: Bigger number means more flashes.
local FLASH_COUNT = 3

-- STUDENT CHANGE: Bigger number means slower flashing.
local FLASH_SPEED = 0.12

-- STUDENT CHANGE: Bigger number makes the checkpoint number float higher.
local NUMBER_HEIGHT = 5

-- STUDENT CHANGE: Change the number color.
local NUMBER_COLOR = Color3.fromRGB(255, 255, 255)

local checkpointFolder = workspace:WaitForChild(CHECKPOINT_FOLDER_NAME)

local raceWinner = nil
local lastTouchTime = {}

local function showFeedback(player, text, color)
	local playerGui = player:FindFirstChild("PlayerGui") or player:WaitForChild("PlayerGui", 2)

	if not playerGui then
		return
	end

	local gui = playerGui:FindFirstChild("RaceFeedbackGui")

	if not gui then
		gui = Instance.new("ScreenGui")
		gui.Name = "RaceFeedbackGui"
		gui.ResetOnSpawn = false
		gui.Parent = playerGui
	end

	local label = gui:FindFirstChild("Message")

	if not label then
		label = Instance.new("TextLabel")
		label.Name = "Message"
		label.Size = UDim2.new(0.6, 0, 0, 60)
		label.Position = UDim2.new(0.2, 0, 0.12, 0)
		label.BackgroundColor3 = Color3.fromRGB(0, 0, 0)
		label.BackgroundTransparency = 0.25
		label.TextScaled = true
		label.Font = Enum.Font.GothamBold
		label.Visible = false
		label.Parent = gui
	end

	label.Text = text
	label.TextColor3 = color
	label.Visible = true

	task.delay(2, function()
		if label and label.Parent and label.Text == text then
			label.Visible = false
		end
	end)
end

local function playSound(player, soundId)
	local playerGui = player:FindFirstChild("PlayerGui") or player:WaitForChild("PlayerGui", 2)

	if not playerGui then
		return
	end

	local sound = Instance.new("Sound")
	sound.SoundId = soundId
	sound.Volume = SOUND_VOLUME
	sound.Parent = playerGui
	sound:Play()

	sound.Ended:Connect(function()
		sound:Destroy()
	end)

	task.delay(5, function()
		if sound and sound.Parent then
			sound:Destroy()
		end
	end)
end

local function flashCheckpoint(checkpoint, flashColor)
	if checkpoint:GetAttribute("IsFlashing") then
		return
	end

	checkpoint:SetAttribute("IsFlashing", true)

	local originalColor = checkpoint.Color
	local originalTransparency = checkpoint.Transparency
	local originalMaterial = checkpoint.Material

	task.spawn(function()
		for _ = 1, FLASH_COUNT do
			checkpoint.Color = flashColor
			checkpoint.Transparency = 0.1
			checkpoint.Material = Enum.Material.Neon

			task.wait(FLASH_SPEED)

			checkpoint.Color = originalColor
			checkpoint.Transparency = originalTransparency
			checkpoint.Material = originalMaterial

			task.wait(FLASH_SPEED)
		end

		checkpoint.Color = originalColor
		checkpoint.Transparency = originalTransparency
		checkpoint.Material = originalMaterial
		checkpoint:SetAttribute("IsFlashing", false)
	end)
end

local function addCheckpointNumber(checkpoint, checkpointNumber)
	local oldLabel = checkpoint:FindFirstChild("CheckpointNumberLabel")

	if oldLabel then
		oldLabel:Destroy()
	end

	local billboard = Instance.new("BillboardGui")
	billboard.Name = "CheckpointNumberLabel"
	billboard.Size = UDim2.new(0, 100, 0, 100)
	billboard.StudsOffset = Vector3.new(0, NUMBER_HEIGHT, 0)
	billboard.AlwaysOnTop = true
	billboard.Parent = checkpoint

	local textLabel = Instance.new("TextLabel")
	textLabel.Size = UDim2.new(1, 0, 1, 0)
	textLabel.BackgroundTransparency = 1
	textLabel.Text = tostring(checkpointNumber)
	textLabel.TextScaled = true
	textLabel.Font = Enum.Font.GothamBlack
	textLabel.TextColor3 = NUMBER_COLOR
	textLabel.TextStrokeTransparency = 0
	textLabel.TextStrokeColor3 = Color3.fromRGB(0, 0, 0)
	textLabel.Parent = billboard
end

local function setupPlayer(player)
	player:SetAttribute("NextCheckpoint", 1)
	player:SetAttribute("FinishedRace", false)

	local leaderstats = player:FindFirstChild("leaderstats")

	if not leaderstats then
		leaderstats = Instance.new("Folder")
		leaderstats.Name = "leaderstats"
		leaderstats.Parent = player
	end

	local checkpointStat = leaderstats:FindFirstChild("Checkpoint")

	if not checkpointStat then
		checkpointStat = Instance.new("IntValue")
		checkpointStat.Name = "Checkpoint"
		checkpointStat.Parent = leaderstats
	end

	checkpointStat.Value = 0

	local wins = leaderstats:FindFirstChild("Wins")

	if not wins then
		wins = Instance.new("IntValue")
		wins.Name = "Wins"
		wins.Parent = leaderstats
	end
end

local function findPlayerFromHit(hit)
	local current = hit

	while current and current ~= workspace do
		if current:IsA("Model") then
			local player = Players:GetPlayerFromCharacter(current)

			if player then
				return player
			end

			local vehicleSeat = current:FindFirstChildWhichIsA("VehicleSeat", true)

			if vehicleSeat and vehicleSeat.Occupant then
				return Players:GetPlayerFromCharacter(vehicleSeat.Occupant.Parent)
			end

			local seat = current:FindFirstChildWhichIsA("Seat", true)

			if seat and seat.Occupant then
				return Players:GetPlayerFromCharacter(seat.Occupant.Parent)
			end
		end

		current = current.Parent
	end

	return nil
end

local function resetRace()
	raceWinner = nil

	for _, player in ipairs(Players:GetPlayers()) do
		player:SetAttribute("NextCheckpoint", 1)
		player:SetAttribute("FinishedRace", false)

		local leaderstats = player:FindFirstChild("leaderstats")
		local checkpointStat = leaderstats and leaderstats:FindFirstChild("Checkpoint")

		if checkpointStat then
			checkpointStat.Value = 0
		end
	end

	print("Race reset. Start at Checkpoint1 again.")
end

local function handleCheckpointTouch(checkpointNumber, checkpoint, hit)
	if raceWinner then
		return
	end

	local player = findPlayerFromHit(hit)

	if not player then
		return
	end

	local touchKey = player.UserId .. "-" .. checkpointNumber
	local now = os.clock()

	if lastTouchTime[touchKey] and now - lastTouchTime[touchKey] < 1 then
		return
	end

	lastTouchTime[touchKey] = now

	local nextCheckpoint = player:GetAttribute("NextCheckpoint") or 1

	if checkpointNumber < nextCheckpoint then
		return
	end

	if checkpointNumber > nextCheckpoint then
		showFeedback(
			player,
			"Wrong checkpoint! Go to Checkpoint" .. nextCheckpoint,
			Color3.fromRGB(255, 80, 80)
		)

		playSound(player, WRONG_SOUND_ID)
		flashCheckpoint(checkpoint, WRONG_FLASH_COLOR)

		print(player.Name .. " touched Checkpoint" .. checkpointNumber .. " but needs Checkpoint" .. nextCheckpoint)
		return
	end

	local leaderstats = player:FindFirstChild("leaderstats")
	local checkpointStat = leaderstats and leaderstats:FindFirstChild("Checkpoint")

	if checkpointStat then
		checkpointStat.Value = checkpointNumber
	end

	showFeedback(
		player,
		"Good! Checkpoint " .. checkpointNumber .. "/" .. CHECKPOINT_COUNT,
		Color3.fromRGB(80, 255, 120)
	)

	playSound(player, CORRECT_SOUND_ID)

	print(player.Name .. " completed Checkpoint" .. checkpointNumber)

	if checkpointNumber == CHECKPOINT_COUNT then
		raceWinner = player
		player:SetAttribute("FinishedRace", true)

		local wins = leaderstats and leaderstats:FindFirstChild("Wins")

		if wins then
			wins.Value += 1
		end

		showFeedback(
			player,
			"You win the race!",
			Color3.fromRGB(255, 215, 0)
		)

		playSound(player, WIN_SOUND_ID)
		flashCheckpoint(checkpoint, WIN_FLASH_COLOR)

		print(player.Name .. " WINS THE RACE!")

		task.wait(RESET_AFTER_WIN_SECONDS)
		resetRace()
	else
		flashCheckpoint(checkpoint, CORRECT_FLASH_COLOR)
		player:SetAttribute("NextCheckpoint", checkpointNumber + 1)
	end
end

Players.PlayerAdded:Connect(setupPlayer)

for _, player in ipairs(Players:GetPlayers()) do
	setupPlayer(player)
end

for checkpointNumber = 1, CHECKPOINT_COUNT do
	local checkpoint = checkpointFolder:WaitForChild("Checkpoint" .. checkpointNumber)

	checkpoint.Anchored = true
	checkpoint.CanCollide = false
	checkpoint.Transparency = math.max(checkpoint.Transparency, 0.5)

	addCheckpointNumber(checkpoint, checkpointNumber)

	checkpoint.Touched:Connect(function(hit)
		handleCheckpointTouch(checkpointNumber, checkpoint, hit)
	end)
end

print("Race checkpoint system ready.")

READY FEATURE

Score Pad

Use this when students want a simple scoring object on the track. A player can walk into it or drive into it to gain PadScore. The pad flashes, plays a sound, disappears for 10 seconds, and then returns for the next player.

WORKFLOW FIRST

  1. Create one Part in Workspace named exactly ScorePad.
  2. Put it anywhere students should score a point.
  3. Duplicate it around the map without changing the name.
  4. Open ServerScriptService, add a new Script, and paste the code below.
  5. Press Play and touch or drive into the pad to test the PadScore leaderboard.

WHY IT WORKS

The script finds every Part named ScorePad, so students can add more scoring objects by duplicating the same pad. The first valid touch scores, then the pad turns off until it respawns.

It uses PadScore instead of Score, so it can run beside checkpoint or target games without mixing those points.

COMPLETE SCRIPT
Paste into ServerScriptService
-- Put this Script in ServerScriptService.
-- Create one or more Parts in Workspace named ScorePad.
-- Roblox uses -- for comments. Python uses # for comments.

local Players = game:GetService("Players")
local Debris = game:GetService("Debris")

-- PART 1: Student settings
-- STUDENT CHANGE: Every score pad Part in Workspace should use this exact name.
local SCORE_PAD_NAME = "ScorePad"

-- STUDENT CHANGE: How many points the player gets when touching the object.
local POINTS_PER_TOUCH = 1

-- STUDENT CHANGE: How long the object disappears after someone scores.
local RESPAWN_SECONDS = 10

-- STUDENT CHANGE: Sound played when someone scores.
local SCORE_SOUND_ID = "rbxassetid://12221967"

-- STUDENT CHANGE: Try 0.3 for quiet, 1 for normal, or 2 for loud.
local SOUND_VOLUME = 1

-- STUDENT CHANGE: Change these colors to change the score effect.
local READY_COLOR = Color3.fromRGB(0, 200, 255)
local SCORE_FLASH_COLOR = Color3.fromRGB(255, 230, 80)

-- STUDENT CHANGE: Change this if you want the object more visible or more transparent.
local READY_TRANSPARENCY = 0.2

-- PART 2: Create the PadScore leaderboard for each player
local function setupPlayer(player)
	local leaderstats = player:FindFirstChild("leaderstats")

	if not leaderstats then
		leaderstats = Instance.new("Folder")
		leaderstats.Name = "leaderstats"
		leaderstats.Parent = player
	end

	local score = leaderstats:FindFirstChild("PadScore")

	if not score then
		score = Instance.new("IntValue")
		score.Name = "PadScore"
		score.Parent = leaderstats
	end
end

-- PART 3: Find the player who touched the score pad
-- This works if the player walks into it OR drives a car into it.
local function findPlayerFromHit(hit)
	local current = hit

	while current and current ~= workspace do
		if current:IsA("Model") then
			local player = Players:GetPlayerFromCharacter(current)

			if player then
				return player
			end

			local vehicleSeat = current:FindFirstChildWhichIsA("VehicleSeat", true)

			if vehicleSeat and vehicleSeat.Occupant then
				return Players:GetPlayerFromCharacter(vehicleSeat.Occupant.Parent)
			end

			local seat = current:FindFirstChildWhichIsA("Seat", true)

			if seat and seat.Occupant then
				return Players:GetPlayerFromCharacter(seat.Occupant.Parent)
			end
		end

		current = current.Parent
	end

	return nil
end

-- PART 4: Play a sound from the score pad
local function playScoreSound(scorePad)
	if SCORE_SOUND_ID == "" then
		return
	end

	local sound = Instance.new("Sound")
	sound.Name = "ScorePadSound"
	sound.SoundId = SCORE_SOUND_ID
	sound.Volume = SOUND_VOLUME
	sound.Parent = scorePad
	sound:Play()

	Debris:AddItem(sound, 5)
end

-- PART 5: Flash the score pad before it disappears
local function flashScorePad(scorePad)
	scorePad.Color = SCORE_FLASH_COLOR
	scorePad.Material = Enum.Material.Neon
	scorePad.Transparency = 0

	local light = Instance.new("PointLight")
	light.Name = "ScorePadLight"
	light.Color = SCORE_FLASH_COLOR
	light.Brightness = 5
	light.Range = 18
	light.Parent = scorePad

	Debris:AddItem(light, 0.4)
end

-- PART 6: Hide the score pad after someone scores
local function hideScorePad(scorePad)
	scorePad.Transparency = 1
	scorePad.CanTouch = false
	scorePad.CanCollide = false
	scorePad.CanQuery = false
end

-- PART 7: Show the score pad again
local function showScorePad(scorePad)
	scorePad.Color = READY_COLOR
	scorePad.Material = Enum.Material.Neon
	scorePad.Transparency = READY_TRANSPARENCY
	scorePad.CanTouch = true
	scorePad.CanCollide = false
	scorePad.CanQuery = true
	scorePad:SetAttribute("IsHidden", false)
end

-- PART 8: Give the player points
local function givePoint(player)
	local leaderstats = player:FindFirstChild("leaderstats")
	local score = leaderstats and leaderstats:FindFirstChild("PadScore")

	if score then
		score.Value += POINTS_PER_TOUCH
	end
end

-- PART 9: Handle one score pad touch
local function handleScorePadTouch(scorePad, hit)
	if scorePad:GetAttribute("IsHidden") then
		return
	end

	local player = findPlayerFromHit(hit)

	if not player then
		return
	end

	scorePad:SetAttribute("IsHidden", true)

	givePoint(player)
	flashScorePad(scorePad)
	playScoreSound(scorePad)

	print(player.Name .. " scored +" .. POINTS_PER_TOUCH)

	task.delay(0.25, function()
		if scorePad and scorePad.Parent then
			hideScorePad(scorePad)
		end
	end)

	task.delay(RESPAWN_SECONDS, function()
		if scorePad and scorePad.Parent then
			showScorePad(scorePad)
		end
	end)
end

-- PART 10: Connect one score pad
local function setupScorePad(scorePad)
	if not scorePad:IsA("BasePart") then
		return false
	end

	if scorePad:GetAttribute("ScorePadReady") then
		return false
	end

	scorePad:SetAttribute("ScorePadReady", true)
	scorePad.Anchored = true
	showScorePad(scorePad)

	scorePad.Touched:Connect(function(hit)
		handleScorePadTouch(scorePad, hit)
	end)

	return true
end

Players.PlayerAdded:Connect(setupPlayer)

for _, player in ipairs(Players:GetPlayers()) do
	setupPlayer(player)
end

-- PART 11: Find every score pad with the same name
local scorePadCount = 0

for _, thing in ipairs(workspace:GetDescendants()) do
	if thing.Name == SCORE_PAD_NAME and setupScorePad(thing) then
		scorePadCount += 1
	end
end

workspace.DescendantAdded:Connect(function(thing)
	if thing.Name == SCORE_PAD_NAME and setupScorePad(thing) then
		scorePadCount += 1
		print("Added ScorePad. Total score pads: " .. scorePadCount)
	end
end)

if scorePadCount == 0 then
	warn("No score pads found. Create Parts in Workspace named " .. SCORE_PAD_NAME)
else
	print("Score pads ready: " .. scorePadCount)
end

READY FEATURE

Rocket Ball

Use this to start a Rocket League style game. Students create one large ball in Roblox Studio, then paste one server script that makes car hits feel more like a soccer kick: quick forward motion, limited spin, light bounce, and automatic respawn if the ball is deleted or falls off the map.

WORKFLOW FIRST

  1. Create one Part in Workspace.
  2. Change the Part shape to Ball and name it exactly RocketBall.
  3. Place the ball in the middle of the map where it should respawn.
  4. Open ServerScriptService, add a new Script, and paste the code below.
  5. Press Play, hit the ball with a car, then tune Attributes such as KickForce, SteerPower, and UpForce.

WHY IT WORKS

The script adds editable Attributes to the ball, so students can change the game feel from the Properties panel without rewriting the full script. KickForce controls the base hit, CarSpeedBonus rewards faster cars, and UpForce controls how much the ball hops upward.

The hit direction uses both the contact angle and the car's facing direction. SteerPower controls that mix, while SpinKeepAmount damps unwanted rotation so a large ball does not keep spinning after each car hit.

COMPLETE SCRIPT
Paste into ServerScriptService
-- Put this Script in ServerScriptService.
-- Create one Sphere Part in Workspace named RocketBall.
-- This version uses the simple kickable-ball idea, adapted for cars.
-- Goal: light soccer feel, no wild spinning, no "hit a brick" feeling.
-- Roblox uses -- for comments. Python uses # for comments.

local Debris = game:GetService("Debris")

-- PART 1: Student settings
-- STUDENT CHANGE: The ball in Workspace must use this exact name.
local BALL_NAME = "RocketBall"

-- These default Attributes are added to the ball if students did not add them yet.
-- Students can click RocketBall in Explorer and change these Attributes in Properties.
local DEFAULT_ATTRIBUTES = {
	-- STUDENT CHANGE: Base speed added when a car hits the ball.
	KickForce = 142,

	-- STUDENT CHANGE: Bigger number means fast cars kick the ball harder.
	-- Try 0.4 for soft, 0.7 for soccer feel, or 1 for very strong.
	CarSpeedBonus = 0.7,

	-- STUDENT CHANGE: Bigger number means the car's facing direction controls the kick more.
	-- Try 0 for contact-angle only, 0.5 for balanced, or 1 for car-facing only.
	SteerPower = 0.9,

	-- STUDENT CHANGE: Bigger number makes the ball hop upward more.
	UpForce = 18,

	-- STUDENT CHANGE: The ball will not be allowed to move faster than this.
	MaxSpeed = 85,

	-- STUDENT CHANGE: Smaller number makes the ball feel lighter.
	BallDensity = 0.035,

	-- STUDENT CHANGE: Smaller number makes the ball roll with less drag.
	BallFriction = 0.03,

	-- STUDENT CHANGE: 0 means no bounce. 1 means very bouncy.
	BouncePower = 0.9,

	-- STUDENT CHANGE: Smaller number stops unwanted spin faster.
	-- Try 0 for almost no spin, 0.2 for a little spin, 0.5 for more spin.
	SpinKeepAmount = 0.12,

	-- STUDENT CHANGE: 0.9 means the car keeps 90% of its forward speed after contact.
	VehicleKeepSpeedAfterHit = 0.9,

	-- STUDENT CHANGE: How many seconds the BodyVelocity kick lasts.
	KickDuration = 0.16,

	-- STUDENT CHANGE: If the ball falls below this Y height, it respawns.
	RespawnY = -50,

	-- STUDENT CHANGE: How many seconds to wait before resetting or replacing the ball.
	RespawnDelay = 3,
}

-- STUDENT CHANGE: Smaller number lets the same car push the ball more often.
local HIT_COOLDOWN_SECONDS = 0.2

local currentBall = nil
local ballTemplate = nil
local startCFrame = nil
local touchConnection = nil
local isRespawning = false
local lastVehicleHitTime = {}

-- PART 2: Make sure the ball has editable Attributes
local function addDefaultAttributes(ball)
	for attributeName, defaultValue in pairs(DEFAULT_ATTRIBUTES) do
		if ball:GetAttribute(attributeName) == nil then
			ball:SetAttribute(attributeName, defaultValue)
		end
	end
end

local function getNumberAttribute(ball, attributeName)
	local value = ball:GetAttribute(attributeName)

	if typeof(value) == "number" then
		return value
	end

	local defaultValue = DEFAULT_ATTRIBUTES[attributeName]
	ball:SetAttribute(attributeName, defaultValue)

	return defaultValue
end

-- PART 3: Prepare the ball physics
local function configureBall(ball)
	addDefaultAttributes(ball)

	if ball:IsA("Part") then
		ball.Shape = Enum.PartType.Ball
	end

	ball.Anchored = false
	ball.CanCollide = true
	ball.CanTouch = true
	ball.CanQuery = true
	ball.Material = Enum.Material.SmoothPlastic

	local ballDensity = math.max(getNumberAttribute(ball, "BallDensity"), 0.01)
	local ballFriction = math.clamp(getNumberAttribute(ball, "BallFriction"), 0, 1)
	local bouncePower = math.clamp(getNumberAttribute(ball, "BouncePower"), 0, 1)

	ball.CustomPhysicalProperties = PhysicalProperties.new(ballDensity, ballFriction, bouncePower, 0.2, 1)

	pcall(function()
		ball:SetNetworkOwner(nil)
	end)
end

-- PART 4: Find the car that hit the ball
local function findVehicleFromHit(hit)
	local current = hit

	while current and current ~= workspace do
		if current:IsA("Model") then
			local vehicleSeat = current:FindFirstChildWhichIsA("VehicleSeat", true)
			local seat = current:FindFirstChildWhichIsA("Seat", true)
			local drivingSeat = vehicleSeat or seat

			if drivingSeat and drivingSeat.Occupant then
				return current, drivingSeat
			end
		end

		current = current.Parent
	end

	return nil, nil
end

-- PART 5: Keep the ball from moving too fast
local function limitBallSpeed(ball)
	local maxSpeed = getNumberAttribute(ball, "MaxSpeed")
	local velocity = ball.AssemblyLinearVelocity

	if velocity.Magnitude > maxSpeed then
		ball.AssemblyLinearVelocity = velocity.Unit * maxSpeed
	end
end

-- PART 6: Choose the kick direction
local function getKickDirection(ball, drivingSeat)
	local angleDirection = ball.Position - drivingSeat.Position
	local flatAngleDirection = Vector3.new(angleDirection.X, 0, angleDirection.Z)
	local aimDirection = drivingSeat.CFrame.LookVector
	local flatAimDirection = Vector3.new(aimDirection.X, 0, aimDirection.Z)
	local steerPower = math.clamp(getNumberAttribute(ball, "SteerPower"), 0, 1)

	if flatAngleDirection.Magnitude < 0.1 then
		flatAngleDirection = flatAimDirection
	end

	if flatAngleDirection.Magnitude < 0.1 or flatAimDirection.Magnitude < 0.1 then
		return nil
	end

	local contactDirection = flatAngleDirection.Unit
	local carAimDirection = flatAimDirection.Unit
	local mixedDirection = (contactDirection * (1 - steerPower)) + (carAimDirection * steerPower)

	if mixedDirection.Magnitude < 0.1 then
		return contactDirection
	end

	return mixedDirection.Unit
end

-- PART 7: Kick the ball with a short BodyVelocity
local function kickBall(ball, kickDirection, kickSpeed)
	local oldVelocity = ball:FindFirstChild("RocketBallKickVelocity")

	if oldVelocity then
		oldVelocity:Destroy()
	end

	local upForce = getNumberAttribute(ball, "UpForce")
	local kickDuration = getNumberAttribute(ball, "KickDuration")
	local maxSpeed = getNumberAttribute(ball, "MaxSpeed")
	local finalSpeed = math.min(kickSpeed, maxSpeed)
	local newVelocity = kickDirection * finalSpeed + Vector3.new(0, upForce, 0)

	ball.AssemblyAngularVelocity = Vector3.zero

	local velocity = Instance.new("BodyVelocity")
	velocity.Name = "RocketBallKickVelocity"
	velocity.MaxForce = Vector3.new(1, 1, 1) * math.huge
	velocity.Velocity = newVelocity
	velocity.Parent = ball

	Debris:AddItem(velocity, kickDuration)

	task.delay(kickDuration, function()
		if ball and ball.Parent then
			ball.AssemblyLinearVelocity = newVelocity
			ball.AssemblyAngularVelocity = ball.AssemblyAngularVelocity * getNumberAttribute(ball, "SpinKeepAmount")
			limitBallSpeed(ball)
		end
	end)
end

-- PART 8: Keep the car from feeling like it hit a brick
local function keepVehicleMoving(ball, drivingSeat)
	local keepSpeedAmount = math.clamp(getNumberAttribute(ball, "VehicleKeepSpeedAfterHit"), 0, 1)
	local forwardDirection = drivingSeat.CFrame.LookVector
	local flatForward = Vector3.new(forwardDirection.X, 0, forwardDirection.Z)

	if flatForward.Magnitude < 0.1 then
		return
	end

	local currentVelocity = drivingSeat.AssemblyLinearVelocity
	local currentFlatVelocity = Vector3.new(currentVelocity.X, 0, currentVelocity.Z)
	local forwardSpeed = currentFlatVelocity:Dot(flatForward.Unit)

	if forwardSpeed < 0 then
		forwardSpeed = 0
	end

	local targetForwardSpeed = math.max(forwardSpeed, getNumberAttribute(ball, "KickForce") * keepSpeedAmount)
	local targetVelocity = flatForward.Unit * targetForwardSpeed

	drivingSeat.AssemblyLinearVelocity = Vector3.new(targetVelocity.X, currentVelocity.Y, targetVelocity.Z)
	drivingSeat.AssemblyAngularVelocity = drivingSeat.AssemblyAngularVelocity * 0.5
end

-- PART 9: Push the ball when a driven car hits it
local function pushBallFromVehicle(ball, vehicle, drivingSeat)
	local kickDirection = getKickDirection(ball, drivingSeat)

	if not kickDirection then
		return
	end

	local kickForce = getNumberAttribute(ball, "KickForce")
	local carSpeedBonus = getNumberAttribute(ball, "CarSpeedBonus")
	local carFlatVelocity = Vector3.new(
		drivingSeat.AssemblyLinearVelocity.X,
		0,
		drivingSeat.AssemblyLinearVelocity.Z
	)
	local kickSpeed = kickForce + carFlatVelocity.Magnitude * carSpeedBonus

	kickBall(ball, kickDirection, kickSpeed)
	keepVehicleMoving(ball, drivingSeat)

	print("RocketBall car kick: " .. vehicle.Name .. " speed=" .. math.floor(kickSpeed))
end

local function handleBallTouch(hit)
	local ball = currentBall

	if not ball or not ball.Parent then
		return
	end

	local vehicle, drivingSeat = findVehicleFromHit(hit)

	if not vehicle or not drivingSeat then
		return
	end

	local now = os.clock()

	if lastVehicleHitTime[vehicle] and now - lastVehicleHitTime[vehicle] < HIT_COOLDOWN_SECONDS then
		return
	end

	lastVehicleHitTime[vehicle] = now
	pushBallFromVehicle(ball, vehicle, drivingSeat)
end

-- PART 10: Connect the active ball
local function setupActiveBall(ball)
	if touchConnection then
		touchConnection:Disconnect()
	end

	currentBall = ball
	configureBall(ball)

	touchConnection = ball.Touched:Connect(handleBallTouch)
end

-- PART 11: Reset an existing ball or replace a deleted ball
local function spawnReplacementBall()
	local newBall = ballTemplate:Clone()
	newBall.Name = BALL_NAME
	newBall.CFrame = startCFrame
	newBall.AssemblyLinearVelocity = Vector3.zero
	newBall.AssemblyAngularVelocity = Vector3.zero
	newBall.Parent = workspace

	setupActiveBall(newBall)
end

local function respawnBall(reason)
	if isRespawning then
		return
	end

	isRespawning = true

	local ballForSettings = currentBall or ballTemplate
	local respawnDelay = getNumberAttribute(ballForSettings, "RespawnDelay")

	print("RocketBall will respawn in " .. respawnDelay .. " seconds: " .. reason)

	task.delay(respawnDelay, function()
		if currentBall and currentBall.Parent then
			currentBall.CFrame = startCFrame
			currentBall.AssemblyLinearVelocity = Vector3.zero
			currentBall.AssemblyAngularVelocity = Vector3.zero
			configureBall(currentBall)
			print("RocketBall reset to the middle.")
		else
			spawnReplacementBall()
			print("RocketBall was replaced at the middle.")
		end

		isRespawning = false
	end)
end

-- PART 12: Start the ball controller
local startingBall = workspace:FindFirstChild(BALL_NAME)

if not startingBall or not startingBall:IsA("BasePart") then
	warn("Create one Sphere Part in Workspace named " .. BALL_NAME)
	return
end

configureBall(startingBall)

startCFrame = startingBall.CFrame
ballTemplate = startingBall:Clone()

setupActiveBall(startingBall)

print("RocketBall car-kick controller ready.")

-- PART 13: Watch the ball while the game runs
while true do
	task.wait(0.25)

	local ball = currentBall

	if not ball or not ball.Parent then
		respawnBall("ball was deleted")
	elseif ball.Position.Y < getNumberAttribute(ball, "RespawnY") then
		respawnBall("ball fell below RespawnY")
	else
		configureBall(ball)
		ball.AssemblyAngularVelocity = ball.AssemblyAngularVelocity * getNumberAttribute(ball, "SpinKeepAmount")
		limitBallSpeed(ball)
	end
end

READY FEATURE

Car Jump and Air Boost

Use this with a Roblox car that already has the standard controller modules. It replaces the existing controller LocalScript so the driver can press Space once to jump and press Space again in the air to boost straight forward into the ball.

WORKFLOW FIRST

  1. In Explorer, open Workspace, then open the car model such as Carblue.
  2. Find the existing controller LocalScript that starts with local Players = game:GetService.
  3. Duplicate the original script or copy it somewhere safe before replacing it.
  4. Replace the whole controller script with the code below.
  5. Press Play, sit in the car, press Space once to jump, then press Space again in the air to boost.

WHY IT WORKS

The script keeps the normal camera, input, speedometer, nitro, and controller loop. The new section listens for Space only while the local player is driving this car.

Students can tune JUMP_UP_SPEED, AIR_BOOST_FORWARD_SPEED, AIR_SPIN_KEEP_AMOUNT, and FLOAT_ASSIST_SECONDS to change the feel. Cloning the car also clones this behavior.

COMPLETE SCRIPT
Replace the existing car controller LocalScript
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

-- ROCKET JUMP NEW: We use UserInputService to listen for the Space key.
local UserInputService = game:GetService("UserInputService")

local Constants = require(script.Parent.Parent.Constants)
local Units = require(script.Parent.Parent.Units)
local Controller = require(script.Parent.Parent.Controller)
local Camera = require(script.Parent.Camera)
local Input = require(script.Parent.Input)
local Speedometer = require(script.Parent.Speedometer)
local setJumpingEnabled = require(script.setJumpingEnabled)
local disconnectAndClear = require(script.Parent.disconnectAndClear)

local destructionHandlerTemplate = script.DestructionHandler
local player = Players.LocalPlayer
local car = script.Parent.Parent.Parent
local driverSeat = car.DriverSeat
local chassis = car.Chassis
local engine = car.Engine
local remotes = car.Remotes
local setNitroEnabledRemote = remotes.SetNitroEnabled
local animations = car.Animations
local driveAnimation = animations.DriveAnimation

local isControlling = false
local connections = {}
local driveAnimationTrack = nil

-- ROCKET JUMP NEW: These variables remember whether this car already jumped or boosted.
local jumpUsed = false
local airBoostUsed = false
local lastSpaceTime = 0
local jumpTime = 0

-- ROCKET JUMP NEW - STUDENT CHANGE:
-- Bigger number makes the first Space press jump higher.
local JUMP_UP_SPEED = 55

-- ROCKET JUMP NEW - STUDENT CHANGE:
-- Bigger number makes the second Space press push forward harder.
-- This is 4x stronger than the first version, which used 95.
local AIR_BOOST_FORWARD_SPEED = 380

-- ROCKET JUMP NEW - STUDENT CHANGE:
-- Adds a little upward lift during the second Space boost.
local AIR_BOOST_UP_SPEED = 16

-- ROCKET JUMP NEW - STUDENT CHANGE:
-- Smaller number allows faster repeated key presses.
local SPACE_COOLDOWN_SECONDS = 0.12

-- ROCKET JUMP NEW:
-- After the car jumps, ignore the ground for a tiny moment so the second Space can work.
local IGNORE_GROUND_AFTER_JUMP_SECONDS = 0.35

-- ROCKET JUMP NEW:
-- How far below the chassis to look for the ground.
local GROUND_CHECK_DISTANCE = 8

-- ROCKET JUMP NEW - STUDENT CHANGE:
-- Smaller number removes more spin and makes the car more stable in the air.
-- Try 0.1 for very stable or 0.5 for more flipping.
local AIR_SPIN_KEEP_AMOUNT = 0.2

-- ROCKET JUMP NEW - STUDENT CHANGE:
-- Bigger number makes the car float longer after Space.
local FLOAT_ASSIST_SECONDS = 0.8

-- ROCKET JUMP NEW - STUDENT CHANGE:
-- 0 means no float help. 1 means almost cancels gravity while floating.
local FLOAT_GRAVITY_CANCEL = 0.65

-- ROCKET JUMP NEW: Check if the car is touching or near the ground.
local function isCarGrounded()
	local raycastParams = RaycastParams.new()
	raycastParams.FilterType = Enum.RaycastFilterType.Exclude
	raycastParams.FilterDescendantsInstances = { car }

	local result = workspace:Raycast(
		chassis.Position,
		Vector3.new(0, -GROUND_CHECK_DISTANCE, 0),
		raycastParams
	)

	return result ~= nil
end

-- ROCKET JUMP NEW: Push the main car assembly.
-- We do NOT set every wheel and body part separately, because that can make the car unstable.
local function setCarVelocity(newVelocity)
	chassis.AssemblyLinearVelocity = newVelocity

	if driverSeat ~= chassis then
		driverSeat.AssemblyLinearVelocity = newVelocity
	end
end

-- ROCKET JUMP NEW: Reduce spin so the car stays easier to control in the air.
local function stabilizeCarSpin()
	chassis.AssemblyAngularVelocity = chassis.AssemblyAngularVelocity * AIR_SPIN_KEEP_AMOUNT

	if driverSeat ~= chassis then
		driverSeat.AssemblyAngularVelocity = driverSeat.AssemblyAngularVelocity * AIR_SPIN_KEEP_AMOUNT
	end
end

-- ROCKET JUMP NEW: Add a short upward force so the car hangs in the air longer.
local function applyFloatAssist()
	local oldForce = chassis:FindFirstChild("RocketJumpFloatForce")

	if oldForce then
		oldForce:Destroy()
	end

	local attachment = chassis:FindFirstChild("RocketJumpFloatAttachment")

	if not attachment then
		attachment = Instance.new("Attachment")
		attachment.Name = "RocketJumpFloatAttachment"
		attachment.Parent = chassis
	end

	local force = Instance.new("VectorForce")
	force.Name = "RocketJumpFloatForce"
	force.Attachment0 = attachment
	force.ApplyAtCenterOfMass = true
	force.RelativeTo = Enum.ActuatorRelativeTo.World
	force.Force = Vector3.new(0, chassis.AssemblyMass * workspace.Gravity * FLOAT_GRAVITY_CANCEL, 0)
	force.Parent = chassis

	task.delay(FLOAT_ASSIST_SECONDS, function()
		if force and force.Parent then
			force:Destroy()
		end
	end)
end

-- ROCKET JUMP NEW: First Space press.
local function jumpCar()
	local oldVelocity = chassis.AssemblyLinearVelocity
	local newVelocity = Vector3.new(oldVelocity.X, JUMP_UP_SPEED, oldVelocity.Z)

	jumpUsed = true
	airBoostUsed = false
	jumpTime = os.clock()

	setCarVelocity(newVelocity)
	stabilizeCarSpin()
	applyFloatAssist()
end

-- ROCKET JUMP NEW: Second Space press while the car is in the air.
local function airBoostCar()
	local forwardDirection = chassis.CFrame.LookVector
	local oldVelocity = chassis.AssemblyLinearVelocity
	local forwardVelocity = forwardDirection.Unit * AIR_BOOST_FORWARD_SPEED
	local newVelocity = forwardVelocity + Vector3.new(0, math.max(oldVelocity.Y, AIR_BOOST_UP_SPEED), 0)

	airBoostUsed = true

	setCarVelocity(newVelocity)
	stabilizeCarSpin()
	applyFloatAssist()
end

-- ROCKET JUMP NEW: Decide whether Space should jump or air boost.
local function handleSpacePressed(input)
	if input.KeyCode ~= Enum.KeyCode.Space then
		return
	end

	if UserInputService:GetFocusedTextBox() then
		return
	end

	local now = os.clock()

	if now - lastSpaceTime < SPACE_COOLDOWN_SECONDS then
		return
	end

	lastSpaceTime = now

	local recentlyJumped = jumpUsed and now - jumpTime < IGNORE_GROUND_AFTER_JUMP_SECONDS
	local grounded = isCarGrounded() and not recentlyJumped

	if grounded then
		jumpUsed = false
		airBoostUsed = false
	end

	if not jumpUsed then
		jumpCar()
	elseif not airBoostUsed then
		airBoostCar()
	end
end

-- Enable the client modules and Controller update loop
local function startControlling()
	if isControlling then
		return
	end
	isControlling = true

	-- Stop the player from jumping out the seat since a separate key is bound to exit the car.
	local character = player.Character
	if character then
		setJumpingEnabled(character, false)

		-- Play the driving animation
		local humanoid = character:FindFirstChildOfClass("Humanoid")
		if humanoid then
			local animator = humanoid:FindFirstChildOfClass("Animator")
			if animator then
				driveAnimationTrack = animator:LoadAnimation(driveAnimation)
				driveAnimationTrack:Play()
			end
		end
	end

	-- Connect the Controller's update function to RunService.Stepped.
	-- Stepped is used here since it fires prior to the physics simulation, and the controller
	-- updates the physics constraints on the car.
	table.insert(
		connections,
		RunService.Stepped:Connect(function(_, deltaTime: number)
			Controller:update(deltaTime)
		end)
	)

	-- ROCKET JUMP NEW: Listen for Space only while this player is driving this car.
	table.insert(
		connections,
		UserInputService.InputBegan:Connect(function(input)
			handleSpacePressed(input)
		end)
	)

	-- Enable replication for the nitro audio and VFX
	table.insert(
		connections,
		engine:GetAttributeChangedSignal(Constants.NITRO_ENABLED_ATTRIBUTE):Connect(function()
			local isNitroEnabled = engine:GetAttribute(Constants.NITRO_ENABLED_ATTRIBUTE)
			setNitroEnabledRemote:FireServer(isNitroEnabled)
		end)
	)

	-- Enable the client modules
	Camera:enable()
	Input:enable()
	Speedometer:enable()
end

-- Disable the client modules and Controller update loop
local function stopControlling()
	if not isControlling then
		return
	end
	isControlling = false

	-- ROCKET JUMP NEW: Reset jump state when the player leaves the car.
	jumpUsed = false
	airBoostUsed = false
	lastSpaceTime = 0
	jumpTime = 0

	-- Disable the client modules
	Camera:disable()
	Input:disable()
	Speedometer:disable()

	-- Disconnect event connections
	disconnectAndClear(connections)

	-- Reset the car Controller. If the car has been destroyed (i.e. is not parented anywhere in the DataModel) this can be skipped.
	if car:IsDescendantOf(game) then
		Controller:reset()
	end

	-- Reenable jumping for the player
	local character = player.Character
	if character then
		setJumpingEnabled(character, true)
	end

	-- Stop the driving animation
	if driveAnimationTrack then
		driveAnimationTrack:Stop()
		driveAnimationTrack = nil
	end
end

-- Call startControlling() or stopControlling() if the player enters or exits the seat
local function onOccupantChanged()
	local isOccupiedByLocalPlayer = false

	local humanoid = driverSeat.Occupant
	if humanoid then
		local character = humanoid.Parent
		local playerInSeat = Players:GetPlayerFromCharacter(character)
		if playerInSeat == player then
			isOccupiedByLocalPlayer = true
		end
	end

	if isOccupiedByLocalPlayer then
		startControlling()
	else
		stopControlling()
	end
end

-- When using Deferred signal mode: since this script is parented to the car, destroying the car will clean up
-- all connections (including .Destroying) before they can actually execute.
-- To avoid this, we'll use a slightly hacky solution and clone a listener script into PlayerScripts.
-- This script will continue running after the car is destroyed and allow us to properly clean up when the car is destroyed.
local function setupDestructionHandler()
	local destructionHandler = destructionHandlerTemplate:Clone()
	destructionHandler.Parent = player.PlayerScripts
	destructionHandler.Enabled = true

	-- Defer firing the BindableEvent so the script is able to initialize first
	task.defer(function()
		destructionHandler.BindToCar:Fire(car, stopControlling)
	end)
end

setupDestructionHandler()
driverSeat:GetPropertyChangedSignal("Occupant"):Connect(onOccupantChanged)

READY FEATURE

Rocket Goal Scoring

Use this after the Rocket Ball script. Students add one red goal and one blue goal, then paste this script to track team points. When RocketBall hits RedGoal, Blue wins a point. When RocketBall hits BlueGoal, Red wins a point.

WORKFLOW FIRST

  1. Finish the Rocket Ball setup first, with one ball named exactly RocketBall.
  2. Create one large red Part in Workspace named exactly RedGoal.
  3. Create one large blue Part in Workspace named exactly BlueGoal.
  4. Set each goal to Anchored = true, CanCollide = false, and CanTouch = true.
  5. Open ServerScriptService, add a new Script, and paste the code below.

WHY IT WORKS

The script watches only the ball and the two goal Parts. It keeps RedScore and BlueScore in ReplicatedStorage, shows a top scoreboard, and displays a center-screen message when a team wins a point.

After a goal, the script plays a celebration sound, flashes the goal, and moves RocketBall back to its starting center position so the game can continue.

COMPLETE SCRIPT
Paste into ServerScriptService
-- Put this Script in ServerScriptService.
-- Use this with the RocketBall script.
-- Create one ball named RocketBall, one red goal named RedGoal, and one blue goal named BlueGoal.
-- Roblox uses -- for comments. Python uses # for comments.

local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Debris = game:GetService("Debris")

-- PART 1: Student settings
-- STUDENT CHANGE: The ball in Workspace must use this exact name.
local BALL_NAME = "RocketBall"

-- STUDENT CHANGE: The red goal is defended by Red. If the ball hits it, Blue scores.
local RED_GOAL_NAME = "RedGoal"

-- STUDENT CHANGE: The blue goal is defended by Blue. If the ball hits it, Red scores.
local BLUE_GOAL_NAME = "BlueGoal"

-- STUDENT CHANGE: Try 2 if you want each goal to be worth more points.
local POINTS_PER_GOAL = 1

-- STUDENT CHANGE: Prevents one goal touch from counting many times.
local GOAL_COOLDOWN_SECONDS = 3

-- STUDENT CHANGE: How long the center-screen message stays visible.
local MESSAGE_SECONDS = 3

-- STUDENT CHANGE: How long to wait before putting the ball back in the middle.
local BALL_RESET_DELAY_SECONDS = 0.5

-- STUDENT CHANGE: Celebration sound. Change the number to test another Roblox sound.
local CELEBRATION_SOUND_ID = "rbxassetid://12222253"

-- STUDENT CHANGE: Try 0.3 for quiet, 1 for normal, or 2 for loud.
local SOUND_VOLUME = 1

-- STUDENT CHANGE: Change these colors if your teams use different colors.
local RED_COLOR = Color3.fromRGB(255, 80, 80)
local BLUE_COLOR = Color3.fromRGB(80, 160, 255)
local FLASH_COLOR = Color3.fromRGB(255, 230, 80)

local redScoreValue = nil
local blueScoreValue = nil
local startCFrame = nil
local goalOnCooldown = false

-- PART 2: Find the ball and goals
local ball = workspace:FindFirstChild(BALL_NAME, true)
local redGoal = workspace:FindFirstChild(RED_GOAL_NAME, true)
local blueGoal = workspace:FindFirstChild(BLUE_GOAL_NAME, true)

if not ball or not ball:IsA("BasePart") then
	warn("Create one Sphere Part in Workspace named " .. BALL_NAME)
	return
end

if not redGoal or not redGoal:IsA("BasePart") then
	warn("Create one Part in Workspace named " .. RED_GOAL_NAME)
	return
end

if not blueGoal or not blueGoal:IsA("BasePart") then
	warn("Create one Part in Workspace named " .. BLUE_GOAL_NAME)
	return
end

startCFrame = ball.CFrame

-- PART 3: Create team score values students can see in ReplicatedStorage
local function getOrCreateScoreValue(scoreFolder, valueName)
	local scoreValue = scoreFolder:FindFirstChild(valueName)

	if not scoreValue then
		scoreValue = Instance.new("IntValue")
		scoreValue.Name = valueName
		scoreValue.Value = 0
		scoreValue.Parent = scoreFolder
	end

	return scoreValue
end

local function setupScoreValues()
	local scoreFolder = ReplicatedStorage:FindFirstChild("RocketTeamScores")

	if not scoreFolder then
		scoreFolder = Instance.new("Folder")
		scoreFolder.Name = "RocketTeamScores"
		scoreFolder.Parent = ReplicatedStorage
	end

	redScoreValue = getOrCreateScoreValue(scoreFolder, "RedScore")
	blueScoreValue = getOrCreateScoreValue(scoreFolder, "BlueScore")
end

-- PART 4: Create the screen message and scoreboard
local function createGui(player)
	local playerGui = player:FindFirstChild("PlayerGui") or player:WaitForChild("PlayerGui", 5)

	if not playerGui then
		return nil
	end

	local gui = playerGui:FindFirstChild("RocketGoalGui")

	if gui then
		return gui
	end

	gui = Instance.new("ScreenGui")
	gui.Name = "RocketGoalGui"
	gui.ResetOnSpawn = false
	gui.Parent = playerGui

	local scoreLabel = Instance.new("TextLabel")
	scoreLabel.Name = "ScoreLabel"
	scoreLabel.AnchorPoint = Vector2.new(0.5, 0)
	scoreLabel.Position = UDim2.fromScale(0.5, 0.04)
	scoreLabel.Size = UDim2.fromOffset(360, 44)
	scoreLabel.BackgroundColor3 = Color3.fromRGB(15, 23, 42)
	scoreLabel.BackgroundTransparency = 0.2
	scoreLabel.BorderSizePixel = 0
	scoreLabel.Font = Enum.Font.GothamBold
	scoreLabel.TextScaled = true
	scoreLabel.TextColor3 = Color3.fromRGB(255, 255, 255)
	scoreLabel.Parent = gui

	local messageLabel = Instance.new("TextLabel")
	messageLabel.Name = "MessageLabel"
	messageLabel.AnchorPoint = Vector2.new(0.5, 0.5)
	messageLabel.Position = UDim2.fromScale(0.5, 0.38)
	messageLabel.Size = UDim2.fromOffset(520, 80)
	messageLabel.BackgroundColor3 = Color3.fromRGB(15, 23, 42)
	messageLabel.BackgroundTransparency = 0.15
	messageLabel.BorderSizePixel = 0
	messageLabel.Font = Enum.Font.GothamBlack
	messageLabel.TextScaled = true
	messageLabel.TextColor3 = Color3.fromRGB(255, 255, 255)
	messageLabel.Visible = false
	messageLabel.Parent = gui

	return gui
end

local function updateScoreboard(player)
	local gui = createGui(player)

	if not gui then
		return
	end

	local scoreLabel = gui:FindFirstChild("ScoreLabel")

	if scoreLabel then
		scoreLabel.Text = "Red " .. redScoreValue.Value .. "   |   Blue " .. blueScoreValue.Value
	end
end

local function updateAllScoreboards()
	for _, player in ipairs(Players:GetPlayers()) do
		updateScoreboard(player)
	end
end

local function showCenterMessage(text, color)
	for _, player in ipairs(Players:GetPlayers()) do
		local gui = createGui(player)

		if gui then
			local messageLabel = gui:FindFirstChild("MessageLabel")

			if messageLabel then
				messageLabel.Text = text
				messageLabel.TextColor3 = color
				messageLabel.Visible = true

				task.delay(MESSAGE_SECONDS, function()
					if messageLabel and messageLabel.Parent and messageLabel.Text == text then
						messageLabel.Visible = false
					end
				end)
			end
		end
	end
end

local function getGoalMessage(scoringTeamName)
	local pointWord = "points"

	if POINTS_PER_GOAL == 1 then
		pointWord = "point"
	end

	return scoringTeamName .. " Team won " .. POINTS_PER_GOAL .. " " .. pointWord .. "!"
end

-- PART 5: Goal effects
local function playCelebrationSound(goal)
	local sound = Instance.new("Sound")
	sound.SoundId = CELEBRATION_SOUND_ID
	sound.Volume = SOUND_VOLUME
	sound.Parent = goal
	sound:Play()

	Debris:AddItem(sound, 4)
end

local function flashGoal(goal)
	local originalColor = goal.Color
	local originalMaterial = goal.Material

	goal.Color = FLASH_COLOR
	goal.Material = Enum.Material.Neon

	local light = Instance.new("PointLight")
	light.Color = FLASH_COLOR
	light.Brightness = 3
	light.Range = 18
	light.Parent = goal

	task.delay(0.5, function()
		if goal and goal.Parent then
			goal.Color = originalColor
			goal.Material = originalMaterial
		end

		if light and light.Parent then
			light:Destroy()
		end
	end)
end

-- PART 6: Reset the ball after a goal
local function resetBallToStart()
	task.delay(BALL_RESET_DELAY_SECONDS, function()
		local currentBall = workspace:FindFirstChild(BALL_NAME, true)

		if currentBall and currentBall:IsA("BasePart") then
			currentBall.CFrame = startCFrame
			currentBall.AssemblyLinearVelocity = Vector3.zero
			currentBall.AssemblyAngularVelocity = Vector3.zero
		end
	end)
end

-- PART 7: Score a goal
local function scoreGoal(scoringTeamName, scoringTeamColor, scoringValue, goal)
	if goalOnCooldown then
		return
	end

	goalOnCooldown = true
	scoringValue.Value += POINTS_PER_GOAL
	local goalMessage = getGoalMessage(scoringTeamName)

	updateAllScoreboards()
	showCenterMessage(goalMessage, scoringTeamColor)
	playCelebrationSound(goal)
	flashGoal(goal)
	resetBallToStart()

	print(goalMessage .. " Red " .. redScoreValue.Value .. " - Blue " .. blueScoreValue.Value)

	task.delay(GOAL_COOLDOWN_SECONDS, function()
		goalOnCooldown = false
	end)
end

-- PART 8: Connect the two goals
local function prepareGoal(goal)
	goal.Anchored = true
	goal.CanCollide = false
	goal.CanTouch = true
end

local function connectGoal(goal, scoringTeamName, scoringTeamColor, scoringValue)
	prepareGoal(goal)

	goal.Touched:Connect(function(hit)
		if hit and hit:IsA("BasePart") and hit.Name == BALL_NAME then
			scoreGoal(scoringTeamName, scoringTeamColor, scoringValue, goal)
		end
	end)
end

setupScoreValues()

Players.PlayerAdded:Connect(function(player)
	task.wait(1)
	updateScoreboard(player)
end)

for _, player in ipairs(Players:GetPlayers()) do
	updateScoreboard(player)
end

connectGoal(redGoal, "Blue", BLUE_COLOR, blueScoreValue)
connectGoal(blueGoal, "Red", RED_COLOR, redScoreValue)

print("Rocket goal scoring ready. RedGoal gives Blue a point. BlueGoal gives Red a point.")

NEXT FEATURES

Planned additions

Finish Line Timer

Record when a player reaches the end of a race.