Immersive Telescope System — Enjoy the stars and planets on the sky


Hey everyone,
I made this telescope system for my own game and figured some of you might like it too.
It’s a local, camera-based telescope script with smooth motion, zoom, blur, and a drifting planet field — simple setup, surprisingly atmospheric.

:telescope: Features

  • ProximityPrompt entry — 0.2s hold to look through, Space to exit
  • Smooth camera control — WASD for rotation (±50° range), with natural momentum
  • Zoom system — Q/E to zoom between 1–40 FOV, dynamic blur + zoom sound
  • Randomized sky — up to 10 planets drifting slowly across view, scaling with zoom
  • Cinematic transitions — fade in/out, UI overlay, subtle drift effect
  • Debug HUD — shows pitch/yaw/FOV if you want to tweak stuff

:video_game: Controls

W / S — Tilt up/down  
A / D — Pan left/right  
Q / E — Zoom in/out  
Space — Exit telescope

Super responsive — hold keys for natural momentum.

:gear: Setup

Just drop the script into:
StarterPlayer > StarterPlayerScripts

Make sure your telescope model has a part named Cam inside it.
You can swap planet textures or the zoom sound ID for your own.

:movie_camera: Showcase

:link: Video demo
:video_game: Game: anemoia, the last warm feelings

The model:
Telescope v2.1.rbxm (448.5 KB)


11 Likes

Make sure to check the video guys =)

-- Coded by @scarrletsky | for https://www.roblox.com/games/129018804998742/anemoia-the-last-warm-feelings


-- E / Q For zoom
-- W A S D For camera rotation
-- Please, credit me or my game. It will be perfect. Wish you a goodluck in your creations! <3

-- =====================================
-- CONFIGURATION (English Docs)
-- =====================================
-- This config table allows full customization. Edit values below as needed.
-- All changes are documented briefly. Reload script after edits.

local Config = {
	-- CONTROLS
	-- Keybinds for actions. Use Enum.KeyCode values (e.g., Enum.KeyCode.E).
	zoomInKey = Enum.KeyCode.Q,      -- Key to zoom in (decrease FOV).
	zoomOutKey = Enum.KeyCode.E,     -- Key to zoom out (increase FOV).
	pitchUpKey = Enum.KeyCode.W,     -- Key to pitch up (look up).
	pitchDownKey = Enum.KeyCode.S,   -- Key to pitch down (look down).
	yawLeftKey = Enum.KeyCode.A,     -- Key to yaw left (turn left).
	yawRightKey = Enum.KeyCode.D,    -- Key to yaw right (turn right).
	exitKey = Enum.KeyCode.Space,    -- Key to exit telescope view.

	-- CAMERA & FOV
	-- Field of view settings.
	originalFOV = 70,                -- Default camera FOV outside telescope.
	initialFOV = 20,                 -- Starting FOV when entering telescope.
	minFOV = 1,                      -- Minimum zoom level (fully zoomed in).
	maxFOV = 60,                     -- Maximum zoom level (zoomed out).
	zoomSpeed = 30,                  -- FOV change speed per second (higher = faster zoom).

	-- MOVEMENT
	-- Pitch/Yaw rotation settings.
	pitchYawAcceleration = 3,        -- Acceleration for pitch/yaw velocity (higher = quicker response).
	pitchYawMaxVelocity = 15,        -- Max velocity for smooth stopping (higher = faster max speed).
	pitchYawDamping = 5,             -- Damping factor for inertia (higher = quicker stop).
	pitchClamp = {-80, 20},         -- Pitch angle limits {min, max} in degrees (positive = up).
	yawClamp = {-50, 50},            -- Yaw angle limits {min, max} in degrees.

	-- PLANET GENERATION
	-- Procedural planet visuals.
	imagePool = {                    -- Array of planet image asset IDs (rbxassetid://).
		"rbxassetid://96187902144866",
		"rbxassetid://122921439218483",
		"rbxassetid://81833840139401",
		"rbxassetid://131303639162154",
		"rbxassetid://123164483372427",
		"rbxassetid://77500182783459",
		"rbxassetid://123081392222008",
		"rbxassetid://99030718560060",
		"rbxassetid://122419204894645"
	},
	maxPlanets = 120,                 -- Maximum simultaneous planets visible.
	genInterval = 5,                 -- Seconds between new planet spawns.
	initialPlanets = 5,              -- Number of planets to spawn on enter.
	planetLifetime = 180,            -- Seconds each planet lives (3 min = 180).
	driftSpeed = 0.02,               -- Sky drift speed (higher = faster background movement).
	planetScaleMin = 0.2,            -- Min random scale multiplier for planets (0.9-1.1 range).
	planetScaleMax = 0.8,            -- Max random scale multiplier for planets.
	planetTransparencyMin = 0.4,     -- Min transparency for planets (0=opaque, 1=invisible).
	planetTransparencyMax = 0.8,     -- Max transparency for planets.
	planetPitchMin = -30,  -- Min relative pitch for generation (lower magnitude = sky-only spawn).
	planetPitchMax = -80,  -- Max relative pitch.

	-- PROMPT
	-- Proximity prompt settings.
	holdDuration = 0.2,              -- Seconds to hold prompt before triggering.
	actionText = "Look Through",     -- Text shown on prompt (e.g., "Use Telescope").
	objectText = "Telescope",        -- Object name on prompt.

	-- SOUNDS
	-- Audio effects.
	zoomSoundId = "rbxassetid://13211461501",  -- Asset ID for zoom sound.
	zoomVolume = 0.4,                -- Volume level (0-1).

	-- GUI & EFFECTS
	-- Visual overlays and transitions.
	overlayImageId = "rbxassetid://94548852777206",  -- Asset ID for telescope overlay image.
	overlaySize = {2.5, 0, 2.5, 0},  -- UDim2 size for overlay {XScale, XOffset, YScale, YOffset}.
	overlayAnchor = {0.32, 0.265},   -- Vector2 anchor point for overlay.
	overlayFadeInTime = 0.5,         -- Seconds for overlay fade-in.
	overlayFadeOutTime = 2,          -- Seconds for overlay fade-out.
	blurMaxSize = 20,                -- Max blur effect intensity during zoom.
	blurChangeSpeed = 100,           -- Blur adjustment speed per second.
	infoEnabled = true,              -- Show debug info text (true/false).
	infoTextSize = 14,               -- Font size for info text.
	infoFont = Enum.Font.Code,       -- Font for info text.

	-- TWEENS
	-- Animation timings (TweenInfo.new(time, style, direction)).
	enterTweenTime = 4,              -- Seconds for camera/FOV tween on enter.
	enterEasingStyle = Enum.EasingStyle.Quad,  -- Easing style for enter tween.
	enterEasingDirection = Enum.EasingDirection.InOut,  -- Easing direction for enter tween.
	exitBlurTweenTime = 4,           -- Seconds for blur fade-out on exit.
	planetFadeInTime = 1,            -- Seconds for new planet fade-in.

	-- PLAYER IMMOBILIZATION
	-- Restrict player movement while in telescope.
	immobilizeEnabled = true,        -- Enable player immobilization (true/false).
	walkSpeedZero = 0,               -- Walk speed while immobilized (usually 0).
	jumpPowerZero = 0,               -- Jump power while immobilized (usually 0).
}
-- =====================================
-- END CONFIGURATION
-- =====================================

local Players = game:GetService("Players")
local TweenService = game:GetService("TweenService")
local UserInputService = game:GetService("UserInputService")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local SoundService = game:GetService("SoundService")

local player = Players.LocalPlayer
local camera = Workspace.CurrentCamera
local playerGui = player:WaitForChild("PlayerGui")

local telescope = Workspace:WaitForChild("Telescope")
local camPart = telescope:WaitForChild("Cam")

local prompt = Instance.new("ProximityPrompt")
prompt.Parent = telescope
prompt.HoldDuration = Config.holdDuration
prompt.ActionText = Config.actionText
prompt.ObjectText = Config.objectText

local inTelescope = false
local originalFOV = Config.originalFOV
local currentFOV = Config.initialFOV
local pitchOffset = 0
local yawOffset = 0
local character = nil
local originalWalkSpeed = 16
local originalJumpPower = 50
local heldKeys = {}
local rsConnection = nil
local pitchVel = 0
local yawVel = 0

local imagePool = Config.imagePool

local telescopeGui = Instance.new("ScreenGui")
telescopeGui.Parent = playerGui
telescopeGui.IgnoreGuiInset = true
telescopeGui.DisplayOrder = 1000
local telescopeImage = Instance.new("ImageLabel")
telescopeImage.Size = UDim2.new(unpack(Config.overlaySize))
telescopeImage.AnchorPoint = Vector2.new(unpack(Config.overlayAnchor))
telescopeImage.Position = UDim2.new(0, 0, 0, 0)
telescopeImage.BackgroundTransparency = 1
telescopeImage.Image = Config.overlayImageId
telescopeImage.ImageTransparency = 1
telescopeImage.ScaleType = Enum.ScaleType.Fit
telescopeImage.ZIndex = 2
telescopeImage.Parent = telescopeGui

local infoGui = Instance.new("ScreenGui")
infoGui.Parent = playerGui
infoGui.IgnoreGuiInset = true
infoGui.DisplayOrder = 1001
local infoText = Instance.new("TextLabel")
infoText.Size = UDim2.new(1, 0, 0, 30)
infoText.Position = UDim2.new(0, 0, 0, 0)
infoText.BackgroundTransparency = 1
infoText.Text = ""
infoText.TextColor3 = Color3.new(1, 1, 1)
infoText.TextSize = Config.infoTextSize
infoText.Font = Config.infoFont
infoText.Parent = infoGui
infoGui.Enabled = Config.infoEnabled

local blurEffect = Instance.new("BlurEffect")
blurEffect.Size = 0
blurEffect.Parent = camera

local planets = {}
local usedCounts = {}
local sky_drift = 0
local last_gen_time = 0
local drift_speed = Config.driftSpeed
local gen_interval = Config.genInterval
local max_planets = Config.maxPlanets

local imageFadeIn = TweenService:Create(telescopeImage, TweenInfo.new(Config.overlayFadeInTime, Enum.EasingStyle.Quad, Enum.EasingDirection.InOut), {ImageTransparency = 0})
local imageFadeOut = TweenService:Create(telescopeImage, TweenInfo.new(Config.overlayFadeOutTime, Enum.EasingStyle.Quad, Enum.EasingDirection.InOut), {ImageTransparency = 1})

local tweenInfoLong = TweenInfo.new(Config.enterTweenTime, Config.enterEasingStyle, Config.enterEasingDirection)

local zoomSoundId = Config.zoomSoundId
local zoomVolume = Config.zoomVolume

player.CharacterRemoving:Connect(function()
	if inTelescope then
		inTelescope = false
		if rsConnection then
			rsConnection:Disconnect()
			rsConnection = nil
		end
		if character and Config.immobilizeEnabled then
			local humanoid = character:FindFirstChild("Humanoid")
			if humanoid then
				humanoid.PlatformStand = false
				humanoid.WalkSpeed = originalWalkSpeed
				humanoid.JumpPower = originalJumpPower
			end
		end
		for _, p in ipairs(planets) do
			p.gui:Destroy()
		end
		planets = {}
		imageFadeOut:Play()
		camera.CameraType = Enum.CameraType.Custom
		camera.FieldOfView = originalFOV
		TweenService:Create(blurEffect, TweenInfo.new(Config.exitBlurTweenTime, Enum.EasingStyle.Quad), {Size = 0}):Play()
		infoText.Text = ""
		heldKeys = {}
		pitchOffset = 0
		yawOffset = 0
		pitchVel = 0
		yawVel = 0
	end
end)

local function updateCameraCFrame()
	local baseCFrame = camPart.CFrame
	local yawRot = CFrame.Angles(0, math.rad(-yawOffset), 0)
	local pitchRot = CFrame.Angles(math.rad(-pitchOffset), 0, 0)
	local newCFrame = baseCFrame * yawRot * pitchRot
	camera.CFrame = newCFrame
end

local function createZoomSound()
	local newSound = Instance.new("Sound")
	newSound.SoundId = zoomSoundId
	newSound.Volume = zoomVolume
	newSound.Parent = SoundService
	newSound:Play()
	newSound.Ended:Connect(function()
		newSound:Destroy()
	end)
end

local function generatePlanet()
	if #planets >= max_planets then return end
	local unused = {}
	local all = {}
	for id, _ in pairs(usedCounts) do
		table.insert(all, id)
		if usedCounts[id] == 0 then
			table.insert(unused, id)
		end
	end
	local picked
	if #unused > 0 and math.random() < 0.9 then
		picked = unused[math.random(1, #unused)]
	else
		picked = all[math.random(1, #all)]
	end
	usedCounts[picked] = (usedCounts[picked] or 0) + 1

	local gui = Instance.new("ImageLabel")
	gui.Name = "Planet"
	gui.Parent = telescopeGui
	gui.ZIndex = 1
	gui.BackgroundTransparency = 1
	gui.Image = picked
	gui.ImageTransparency = 1
	gui.ScaleType = Enum.ScaleType.Fit
	local rand_scale = Config.planetScaleMin + math.random() * (Config.planetScaleMax - Config.planetScaleMin)
	local scale = 20 / currentFOV
	gui.Size = UDim2.new(0, 33 * scale * rand_scale, 0, 33 * scale * rand_scale)
	local trans = Config.planetTransparencyMin + math.random() * (Config.planetTransparencyMax - Config.planetTransparencyMin)
	TweenService:Create(gui, TweenInfo.new(Config.planetFadeInTime, Enum.EasingStyle.Quad, Enum.EasingDirection.InOut), {ImageTransparency = trans}):Play()

	local aspect_ratio = camera.ViewportSize.X / camera.ViewportSize.Y
	local h_fov = 2 * math.deg(math.atan(math.tan(math.rad(currentFOV) / 2) * aspect_ratio))
	local rel_yaw = math.random(-h_fov * 0.5, h_fov * 1.5)
	local low_pitch = math.min(Config.planetPitchMin, Config.planetPitchMax)
	local high_pitch = math.max(Config.planetPitchMin, Config.planetPitchMax)
	local rel_pitch = math.random(low_pitch, high_pitch)
	local sky_yaw = yawOffset + rel_yaw
	local sky_pitch = pitchOffset + rel_pitch

	table.insert(planets, {gui = gui, sky_yaw = sky_yaw, sky_pitch = sky_pitch, rand_scale = rand_scale, spawn_time = tick()})
end

prompt.Triggered:Connect(function()
	if inTelescope then return end
	inTelescope = true
	originalFOV = camera.FieldOfView
	currentFOV = Config.initialFOV
	pitchOffset = 0
	yawOffset = 0
	pitchVel = 0
	yawVel = 0
	character = player.Character
	if not character then
		character = player.CharacterAdded:Wait()
	end
	local humanoid = character:WaitForChild("Humanoid")
	if Config.immobilizeEnabled then
		originalWalkSpeed = humanoid.WalkSpeed
		originalJumpPower = humanoid.JumpPower
		humanoid.PlatformStand = true
		humanoid.WalkSpeed = Config.walkSpeedZero
		humanoid.JumpPower = Config.jumpPowerZero
	end

	for _, id in ipairs(imagePool) do
		usedCounts[id] = 0
	end
	planets = {}
	sky_drift = 0
	last_gen_time = tick() - gen_interval

	for i = 1, Config.initialPlanets do
		generatePlanet()
	end

	camera.CameraType = Enum.CameraType.Scriptable

	local tweenCFrame = TweenService:Create(camera, tweenInfoLong, {CFrame = camPart.CFrame})
	local tweenFOV = TweenService:Create(camera, tweenInfoLong, {FieldOfView = currentFOV})

	tweenCFrame:Play()
	tweenFOV:Play()
	imageFadeIn:Play()

	rsConnection = RunService.Heartbeat:Connect(function(dt)
		if not inTelescope then return end

		local prevFOV_frame = currentFOV
		local prevFloor = math.floor(prevFOV_frame)
		local zoomed = false

		local pitchInput = 0
		if heldKeys[Config.pitchUpKey] then pitchInput = 1 end
		if heldKeys[Config.pitchDownKey] then pitchInput = -1 end
		pitchVel = pitchVel + pitchInput * Config.pitchYawAcceleration * dt
		if pitchInput == 0 then
			pitchVel = pitchVel * math.exp(-Config.pitchYawDamping * dt)
		else
			pitchVel = math.clamp(pitchVel, -Config.pitchYawMaxVelocity, Config.pitchYawMaxVelocity)
		end
		pitchOffset = math.clamp(pitchOffset + pitchVel * dt, Config.pitchClamp[1], Config.pitchClamp[2])

		local yawInput = 0
		if heldKeys[Config.yawLeftKey] then yawInput = -1 end
		if heldKeys[Config.yawRightKey] then yawInput = 1 end
		yawVel = yawVel + yawInput * Config.pitchYawAcceleration * dt
		if yawInput == 0 then
			yawVel = yawVel * math.exp(-Config.pitchYawDamping * dt)
		else
			yawVel = math.clamp(yawVel, -Config.pitchYawMaxVelocity, Config.pitchYawMaxVelocity)
		end
		yawOffset = math.clamp(yawOffset + yawVel * dt, Config.yawClamp[1], Config.yawClamp[2])

		if heldKeys[Config.zoomInKey] then
			currentFOV = math.max(Config.minFOV, currentFOV - Config.zoomSpeed * dt)
			zoomed = true
		end
		if heldKeys[Config.zoomOutKey] then
			currentFOV = math.min(Config.maxFOV, currentFOV + Config.zoomSpeed * dt)
			zoomed = true
		end

		local deltaFOV = math.abs(currentFOV - prevFOV_frame)
		camera.FieldOfView = currentFOV

		local changing = zoomed and deltaFOV > 0.01
		if changing then
			blurEffect.Size = math.min(blurEffect.Size + Config.blurChangeSpeed * dt, Config.blurMaxSize)
		else
			blurEffect.Size = math.max(blurEffect.Size - Config.blurChangeSpeed * dt, 0)
		end

		local newFloor = math.floor(currentFOV)
		if newFloor ~= prevFloor then
			createZoomSound()
		end

		sky_drift = sky_drift - drift_speed * dt

		local aspect_ratio = camera.ViewportSize.X / camera.ViewportSize.Y
		local h_fov = 2 * math.deg(math.atan(math.tan(math.rad(currentFOV) / 2) * aspect_ratio))

		for i = #planets, 1, -1 do
			local p = planets[i]
			if tick() - p.spawn_time > Config.planetLifetime then
				p.gui:Destroy()
				table.remove(planets, i)
			else
				local rel_yaw = p.sky_yaw - yawOffset + sky_drift
				local rel_pitch = p.sky_pitch - pitchOffset
				local screenX = 0.5 + rel_yaw / h_fov
				local screenY = 0.5 + rel_pitch / currentFOV
				if screenX < -0.2 then
					p.sky_yaw = p.sky_yaw + h_fov * 2.4
				end
				p.gui.Position = UDim2.new(screenX, 0, screenY, 0)
				local scale = 20 / currentFOV
				p.gui.Size = UDim2.new(0, 33 * scale * p.rand_scale, 0, 33 * scale * p.rand_scale)
			end
		end

		if tick() - last_gen_time > gen_interval and #planets < max_planets then
			generatePlanet()
			last_gen_time = tick()
		end

		if Config.infoEnabled then
			infoText.Text = string.format("Pitch: %.2f°, Yaw: %.2f°, FOV: %.2f, Planets: %d, Drift: %.2f", pitchOffset, yawOffset, currentFOV, #planets, sky_drift)
		end

		updateCameraCFrame()
	end)
end)

UserInputService.InputBegan:Connect(function(input, gameProcessed)
	if gameProcessed then return end
	if not inTelescope then return end

	heldKeys[input.KeyCode] = true

	if input.KeyCode == Config.exitKey then
		inTelescope = false
		if rsConnection then
			rsConnection:Disconnect()
			rsConnection = nil
		end
		if character and Config.immobilizeEnabled then
			local humanoid = character:FindFirstChild("Humanoid")
			if humanoid then
				humanoid.PlatformStand = false
				humanoid.WalkSpeed = originalWalkSpeed
				humanoid.JumpPower = originalJumpPower
			end
		end
		pitchOffset = 0
		yawOffset = 0
		pitchVel = 0
		yawVel = 0
		heldKeys = {}
		for _, p in ipairs(planets) do
			p.gui:Destroy()
		end
		planets = {}
		imageFadeOut:Play()
		imageFadeOut.Completed:Connect(function()
			camera.CameraType = Enum.CameraType.Custom
			camera.FieldOfView = originalFOV
			TweenService:Create(blurEffect, tweenInfoLong, {Size = 0}):Play()
			infoText.Text = ""
		end)
	end
end)

UserInputService.InputEnded:Connect(function(input, gameProcessed)
	if gameProcessed then return end
	heldKeys[input.KeyCode] = nil
end)

THIS IS SO COOL

1 Like

Updated to v2.1, so its a final version, i fixed and improved everything.
Now its perfect.

this is amazing. as someone living in a city full of lights with no telescope, this is amazing.

1 Like

thanks you! I will update it again later, fix optimization and add more planets

This is a wonderful tool for an immersive experience.

A couple of notes however:

  • Real telescope magnifies discretely, not consciously (similar to phones & cameras).
    This is because in optical telescopes, eyepieces come in fixed focal lengths such as 25 mm, 10 mm, etc. so every time you change magnification, you’re swapping to a different optical element. Each swap is a discrete step.
  • For that model telescope in the showcase, the view you get would be inverted.

But I assume it’s not that serious so… don’t be persuaded to make these adjustments (I’m just a nerd). Nevertheless, very well made!

Thanks. I know, I made the zoom this way because it seemed more pleasant and atmospheric to me than a sharp zoom.