Portfolio · Roblox · Luau

I write Luau and build VFX for Roblox games.

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

Watch one dash run.

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.

  1. 01Input. Q is bound. The client checks the cooldown and starts the dash.
  2. 02Dash. The character moves 28 studs in 0.18 seconds, and the server adds the trail so other players see it.
  3. 03Impact. The hit calls HitSpark for the sparks and CameraShake for the camera.
  4. 04Portal. PortalFX opens the portal and the player walks through it.

02 — Effects

Things that go whoosh.

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.

Portal

A spinning ring with particles pulled toward the middle. PortalFX builds it and handles opening and closing.

PortalFX.luau ↓
click

Hit spark

A burst of particles and a short light flash where something gets hit. Click the box to fire it.

HitSpark.luau ↓
Q

Dash trail

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 code.

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.

01

Dash input

DashClient.client.luau

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.

DashClient.client.luau87 lines
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")
02

Dash on the server

DashService.server.luau

Checks the cooldown, the direction and that the player is alive, then adds the trail so other players can see it.

DashService.server.luau78 lines
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)
03

Hit sparks

HitSpark.luau

Call it with a raycast result and it makes a burst of sparks off the surface, with a quick flash of light.

HitSpark.luau79 lines
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
04

Portals

PortalFX.luau

Creates a glowing, spinning disc with particles pulled toward the center. It has two functions, open and close.

PortalFX.luau96 lines
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
05

Camera shake

CameraShake.luau

Uses noise instead of random numbers so the camera sways smoothly. Several shakes can run at once and each fades out on its own.

CameraShake.luau58 lines
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
06

Saving data

DataService.luau

Saves player data with retries, a lock so two servers can’t overwrite each other, autosave, and a final save when the server closes.

DataService.luau155 lines
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

How I work.

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.

Contact.

If you have a game that needs scripting or effects, send me an email.