Portal
A spinning ring with particles pulled toward the middle. PortalFX builds it and handles opening and closing.
PortalFX.luau ↓
Portfolio · Roblox · Luau
I’m William, a freshman at TJHSST. I’ve been scripting and making effects in Roblox Studio for about six years.
This page has the systems I reuse the most: dashing, hit effects, portals, camera shake and saving player data.
01 — Scene
Keep scrolling. The dash script, the hit effect and a portal all run in one scene, and the log in the corner prints what each script is doing. It’s an animation I made for this page, not footage from a game.
02 — Effects
These are CSS copies so they run in your browser. In Studio they’re built from particle emitters, trails and neon parts, and the scripts are further down.
A spinning ring with particles pulled toward the middle. PortalFX builds it and handles opening and closing.
PortalFX.luau ↓A burst of particles and a short light flash where something gets hit. Click the box to fire it.
HitSpark.luau ↓A trail that fades from blue to gold behind the player during a dash. The server creates it so everyone sees it.
DashService.server.luau ↓03 — Scripts
The scripts from my actual games aren’t public, because people could copy them. These are separate scripts I wrote for this site that work the same way.
None of them have comments.
Press Q to dash in the direction you’re moving, or forward if you’re standing still. It also widens the field of view for a moment.
local Players = game:GetService("Players")
local ContextActionService = game:GetService("ContextActionService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TweenService = game:GetService("TweenService")
local player = Players.LocalPlayer
local dashRemote = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("Dash")
local COOLDOWN = 1.2
local DISTANCE = 28
local DURATION = 0.18
local FOV_PUNCH = 12
local lastDash = 0
local dashing = false
local function getRoot()
local character = player.Character
return character and character:FindFirstChild("HumanoidRootPart")
end
local function getDirection(root)
local humanoid = root.Parent:FindFirstChildOfClass("Humanoid")
local move = humanoid and humanoid.MoveDirection or Vector3.zero
if move.Magnitude < 0.1 then
local look = root.CFrame.LookVector
return Vector3.new(look.X, 0, look.Z).Unit
end
return move.Unit
end
local function punchFov()
local camera = workspace.CurrentCamera
local base = camera.FieldOfView
local out = TweenService:Create(camera, TweenInfo.new(DURATION * 0.5, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
FieldOfView = base + FOV_PUNCH,
})
local back = TweenService:Create(camera, TweenInfo.new(0.35, Enum.EasingStyle.Quint, Enum.EasingDirection.Out), {
FieldOfView = base,
})
out.Completed:Once(function()
back:Play()
end)
out:Play()
end
local function dash()
local now = os.clock()
if dashing or now - lastDash < COOLDOWN then
return
end
local root = getRoot()
local attachment = root and root:FindFirstChild("RootAttachment")
if not attachment then
return
end
lastDash = now
dashing = true
local direction = getDirection(root)
local mover = Instance.new("LinearVelocity")
mover.Attachment0 = attachment
mover.RelativeTo = Enum.ActuatorRelativeTo.World
mover.MaxForce = math.huge
mover.VectorVelocity = direction * (DISTANCE / DURATION)
mover.Parent = root
punchFov()
dashRemote:FireServer(direction)
task.delay(DURATION, function()
mover:Destroy()
dashing = false
end)
end
ContextActionService:BindAction("Dash", function(_, state)
if state == Enum.UserInputState.Begin then
dash()
end
return Enum.ContextActionResult.Sink
end, true, Enum.KeyCode.Q, Enum.KeyCode.ButtonL1)
ContextActionService:SetTitle("Dash", "Dash")
Checks the cooldown, the direction and that the player is alive, then adds the trail so other players can see it.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Debris = game:GetService("Debris")
local remote = ReplicatedStorage:WaitForChild("Remotes"):WaitForChild("Dash")
local COOLDOWN = 1.0
local TRAIL_LIFETIME = 0.35
local DASH_TIME = 0.18
local lastUse = {}
local BLUE = Color3.fromRGB(103, 128, 202)
local GOLD = Color3.fromRGB(202, 182, 103)
local function attachTrail(character)
local root = character:FindFirstChild("HumanoidRootPart")
if not root then
return
end
local top = Instance.new("Attachment")
top.Name = "DashTrailTop"
top.Position = Vector3.new(0, 1.6, 0)
top.Parent = root
local bottom = Instance.new("Attachment")
bottom.Name = "DashTrailBottom"
bottom.Position = Vector3.new(0, -1.6, 0)
bottom.Parent = root
local trail = Instance.new("Trail")
trail.Attachment0 = top
trail.Attachment1 = bottom
trail.Lifetime = TRAIL_LIFETIME
trail.MinLength = 0.05
trail.LightEmission = 0.85
trail.FaceCamera = true
trail.Color = ColorSequence.new(BLUE, GOLD)
trail.Transparency = NumberSequence.new(0.15, 1)
trail.WidthScale = NumberSequence.new({
NumberSequenceKeypoint.new(0, 1),
NumberSequenceKeypoint.new(1, 0),
})
trail.Parent = root
local lifetime = DASH_TIME + TRAIL_LIFETIME + 0.1
Debris:AddItem(trail, lifetime)
Debris:AddItem(top, lifetime)
Debris:AddItem(bottom, lifetime)
end
remote.OnServerEvent:Connect(function(player, direction)
if typeof(direction) ~= "Vector3" then
return
end
if direction.Magnitude < 0.5 or direction.Magnitude > 1.5 then
return
end
local now = os.clock()
if now - (lastUse[player] or 0) < COOLDOWN then
return
end
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if not humanoid or humanoid.Health <= 0 then
return
end
lastUse[player] = now
attachTrail(character)
end)
Players.PlayerRemoving:Connect(function(player)
lastUse[player] = nil
end)
Call it with a raycast result and it makes a burst of sparks off the surface, with a quick flash of light.
local Debris = game:GetService("Debris")
local TweenService = game:GetService("TweenService")
local HitSpark = {}
local template
local function buildTemplate()
local emitter = Instance.new("ParticleEmitter")
emitter.Texture = "rbxasset://textures/particles/sparkles_main.dds"
emitter.Color = ColorSequence.new(Color3.fromRGB(255, 244, 214), Color3.fromRGB(202, 182, 103))
emitter.LightEmission = 1
emitter.LightInfluence = 0
emitter.Size = NumberSequence.new({
NumberSequenceKeypoint.new(0, 0.9),
NumberSequenceKeypoint.new(1, 0),
})
emitter.Transparency = NumberSequence.new({
NumberSequenceKeypoint.new(0, 0),
NumberSequenceKeypoint.new(0.7, 0.2),
NumberSequenceKeypoint.new(1, 1),
})
emitter.Lifetime = NumberRange.new(0.25, 0.5)
emitter.Speed = NumberRange.new(18, 34)
emitter.SpreadAngle = Vector2.new(55, 55)
emitter.EmissionDirection = Enum.NormalId.Front
emitter.Drag = 6
emitter.Acceleration = Vector3.new(0, -35, 0)
emitter.Rotation = NumberRange.new(0, 360)
emitter.Rate = 0
emitter.Enabled = false
return emitter
end
local function flash(parent)
local light = Instance.new("PointLight")
light.Color = Color3.fromRGB(255, 232, 170)
light.Brightness = 4
light.Range = 10
light.Parent = parent
local fade = TweenService:Create(light, TweenInfo.new(0.18, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), {
Brightness = 0,
Range = 2,
})
fade:Play()
end
function HitSpark.burst(position, normal, count)
template = template or buildTemplate()
local facing = normal or Vector3.yAxis
local anchor = Instance.new("Part")
anchor.Name = "HitSpark"
anchor.Anchored = true
anchor.CanCollide = false
anchor.CanQuery = false
anchor.CanTouch = false
anchor.Transparency = 1
anchor.Size = Vector3.one * 0.2
anchor.CFrame = CFrame.lookAt(position, position + facing)
anchor.Parent = workspace
local emitter = template:Clone()
emitter.Parent = anchor
emitter:Emit(count or 18)
flash(anchor)
Debris:AddItem(anchor, 1)
end
function HitSpark.fromRaycast(result, count)
if not result then
return
end
HitSpark.burst(result.Position, result.Normal, count)
end
return HitSpark
Creates a glowing, spinning disc with particles pulled toward the center. It has two functions, open and close.
local RunService = game:GetService("RunService")
local TweenService = game:GetService("TweenService")
local PortalFX = {}
PortalFX.__index = PortalFX
local BLUE = Color3.fromRGB(103, 128, 202)
local GOLD = Color3.fromRGB(202, 182, 103)
local SPIN_SPEED = 1.6
local function makeCore(radius)
local core = Instance.new("Part")
core.Name = "PortalCore"
core.Shape = Enum.PartType.Cylinder
core.Material = Enum.Material.Neon
core.Color = BLUE
core.Transparency = 0.35
core.Anchored = true
core.CanCollide = false
core.CanQuery = false
core.CanTouch = false
core.CastShadow = false
core.Size = Vector3.new(0.15, 0.1, 0.1)
return core
end
local function makeSwirl(core, radius)
local swirl = Instance.new("ParticleEmitter")
swirl.Texture = "rbxasset://textures/particles/sparkles_main.dds"
swirl.Color = ColorSequence.new(BLUE, GOLD)
swirl.LightEmission = 1
swirl.Size = NumberSequence.new({
NumberSequenceKeypoint.new(0, 0),
NumberSequenceKeypoint.new(0.3, 0.6),
NumberSequenceKeypoint.new(1, 0),
})
swirl.Lifetime = NumberRange.new(0.9, 1.4)
swirl.Speed = NumberRange.new(radius * 0.6, radius * 1.1)
swirl.Shape = Enum.ParticleEmitterShape.Disc
swirl.ShapeInOut = Enum.ParticleEmitterShapeInOut.Inward
swirl.ShapeStyle = Enum.ParticleEmitterShapeStyle.Volume
swirl.EmissionDirection = Enum.NormalId.Right
swirl.Rate = 0
swirl.Parent = core
return swirl
end
function PortalFX.new(cframe, radius)
radius = radius or 6
local self = setmetatable({}, PortalFX)
self.radius = radius
self.base = cframe * CFrame.Angles(0, math.rad(90), 0)
self.angle = 0
self.core = makeCore(radius)
self.core.CFrame = self.base
self.core.Parent = workspace
self.swirl = makeSwirl(self.core, radius)
self.connection = nil
return self
end
function PortalFX:open()
local grow = TweenService:Create(self.core, TweenInfo.new(0.6, Enum.EasingStyle.Back, Enum.EasingDirection.Out), {
Size = Vector3.new(0.15, self.radius * 2, self.radius * 2),
Transparency = 0.35,
})
grow:Play()
self.swirl.Rate = 90
self.connection = RunService.Heartbeat:Connect(function(dt)
self.angle += dt * SPIN_SPEED
self.core.CFrame = self.base * CFrame.Angles(self.angle, 0, 0)
end)
end
function PortalFX:close()
if self.connection then
self.connection:Disconnect()
self.connection = nil
end
self.swirl.Rate = 0
local shrink = TweenService:Create(self.core, TweenInfo.new(0.4, Enum.EasingStyle.Quad, Enum.EasingDirection.In), {
Size = Vector3.new(0.15, 0.1, 0.1),
Transparency = 1,
})
shrink.Completed:Once(function()
self.core:Destroy()
end)
shrink:Play()
end
return PortalFX
Uses noise instead of random numbers so the camera sways smoothly. Several shakes can run at once and each fades out on its own.
local RunService = game:GetService("RunService")
local CameraShake = {}
local BIND_NAME = "CameraShake"
local active = {}
local bound = false
local function step()
local now = os.clock()
local offset = Vector3.zero
for index = #active, 1, -1 do
local shake = active[index]
local alpha = (now - shake.start) / shake.duration
if alpha >= 1 then
table.remove(active, index)
else
local falloff = (1 - alpha) ^ 2
local n = now * shake.frequency
local x = math.noise(n, 0, shake.seed)
local y = math.noise(0, n, shake.seed)
offset += Vector3.new(x, y, 0) * shake.magnitude * falloff
end
end
if #active == 0 then
RunService:UnbindFromRenderStep(BIND_NAME)
bound = false
return
end
local camera = workspace.CurrentCamera
local roll = math.rad(offset.X * 1.5)
camera.CFrame = camera.CFrame * CFrame.new(offset) * CFrame.Angles(0, 0, roll)
end
function CameraShake.shake(magnitude, duration, frequency)
table.insert(active, {
start = os.clock(),
duration = duration or 0.4,
magnitude = magnitude or 0.4,
frequency = frequency or 22,
seed = math.random() * 100,
})
if not bound then
bound = true
RunService:BindToRenderStep(BIND_NAME, Enum.RenderPriority.Camera.Value + 1, step)
end
end
function CameraShake.stop()
table.clear(active)
end
return CameraShake
Saves player data with retries, a lock so two servers can’t overwrite each other, autosave, and a final save when the server closes.
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local DataService = {}
local store = DataStoreService:GetDataStore("PlayerData_v1")
local DEFAULT = {
coins = 0,
level = 1,
inventory = {},
}
local MAX_RETRIES = 4
local LOCK_TIMEOUT = 120
local AUTOSAVE_INTERVAL = 90
local profiles = {}
local function deepCopy(source)
local copy = {}
for key, value in source do
copy[key] = type(value) == "table" and deepCopy(value) or value
end
return copy
end
local function reconcile(data, template)
for key, value in template do
if data[key] == nil then
data[key] = type(value) == "table" and deepCopy(value) or value
elseif type(value) == "table" and type(data[key]) == "table" then
reconcile(data[key], value)
end
end
return data
end
local function withRetry(callback)
local lastError
for attempt = 1, MAX_RETRIES do
local ok, result = pcall(callback)
if ok then
return true, result
end
lastError = result
task.wait(0.25 * 2 ^ attempt)
end
return false, lastError
end
local function keyFor(player)
return "u_" .. player.UserId
end
local function load(player)
local blocked = false
local ok, record = withRetry(function()
return store:UpdateAsync(keyFor(player), function(old)
old = old or {}
local lockedElsewhere = old.lock ~= nil
and old.lock ~= game.JobId
and os.time() - (old.lockTime or 0) < LOCK_TIMEOUT
if lockedElsewhere then
blocked = true
return nil
end
blocked = false
old.lock = game.JobId
old.lockTime = os.time()
return old
end)
end)
if not player.Parent then
return
end
if not ok or blocked or not record then
player:Kick("Your data is still saving in another server. Give it a few seconds and rejoin.")
return
end
profiles[player] = reconcile(record.profile or {}, DEFAULT)
end
local function save(player, release)
local profile = profiles[player]
if not profile then
return
end
withRetry(function()
return store:UpdateAsync(keyFor(player), function(old)
old = old or {}
old.profile = profile
if release then
old.lock = nil
old.lockTime = nil
else
old.lock = game.JobId
old.lockTime = os.time()
end
return old
end)
end)
end
function DataService.get(player)
return profiles[player]
end
function DataService.start()
Players.PlayerAdded:Connect(load)
for _, player in Players:GetPlayers() do
task.spawn(load, player)
end
Players.PlayerRemoving:Connect(function(player)
save(player, true)
profiles[player] = nil
end)
task.spawn(function()
while true do
task.wait(AUTOSAVE_INTERVAL)
for player in profiles do
task.spawn(save, player, false)
end
end
end)
game:BindToClose(function()
if RunService:IsStudio() then
return
end
local pending = 0
for player in profiles do
pending += 1
task.spawn(function()
save(player, true)
pending -= 1
end)
end
while pending > 0 do
task.wait()
end
end)
end
return DataService
I started in Roblox Studio in 2020 and haven’t stopped. Anything that matters, like cooldowns and saving data, runs on the server. The client only handles how things look and feel.
I try to name things so the code reads on its own, and I clean up every part, connection and tween I create.
If you have a game that needs scripting or effects, send me an email.