How to make Friday Night Funkin' in Roblox PART 2

Hello everyone! I’m very sorry for the delay, I had to fix lag issues with the engine.

This is part 2 of How to make Friday Night Funkin’ in Roblox.
If you didn’t see part 1, you can find it here

Why did I make this tutorial?

So, about a month ago, I wanted to make a FNF game.
There were no tutorials that I could follow, so I made an engine from the game’s source code. (It’s open source!) You can find it at this github repository .

Any other info?
I recommend having at least some experience in Lua and Roblox Studio. It would be good if you knew the basics and can understand code. I will explain the code and the math to some degree with comments, but I won’t go super in-depth.
I will try to thoroughly explain any concepts like the image scaling, using original size and stuff like that though.

All of the code was sourced from the original game .

Credits
Thanks to ninjamuffin99, PhantomArcade3K, Kawaisprite, Evilsk8r, and the rest of the Friday Night Funkin’ team for the original game !

If you plan on making a game with this, please credit them.
I take zero credit for anything here. All I did was porting to Roblox.

This is going to be a pretty long tutorial, so I split it into parts. This is part 2.

1. Writing the core libraries
Currently, we need 2 core libraries for the game to function properly.
They are:
Conductor and MusicBeatState

The Conductor manages the SongPosition, BPM, Crochet (for calculating beats from milliseconds), StepCrochet (for calculating steps from milliseconds), SafeFrames (for hit detection), and SafeZoneOffset (SafeFrames in milliseconds).

MusicBeatState handles calculating the current step and beat. these two variables are super important in FNF. A beat is roughly 1/4 of a section, and a step is 1/4 of a beat. (a section is 16 steps long, I’ll explain sections later when we get into JSON chart parsing.) If a song has a BPM of 60, one beat will occur every second, and a step will occur ever 250 milliseconds.

Ok, lets start creating the Conductor first.
(Couldn’t show the Haxe one due to character limit)

(This is written in Haxe by the way.)

I actually don’t know Haxe, but it seems pretty straightforward.
Here is my transcription to Lua.

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Source = ReplicatedStorage:WaitForChild("Source")

--\\ The Source variable should be used to require anything that you need later if you are adding on to this library. EX: local Util = require(Source.Util)

local Conductor = {}

function Conductor.Initialize()
	--\\ Just put all the variables in an initialize function so we can reset the conductor.
	Conductor.BPM = 100
	Conductor.Crochet = ((60 / Conductor.BPM) * 1000)
	Conductor.StepCrochet = (Conductor.Crochet / 4)
	Conductor.SongPosition = 0
	Conductor.LastSongPosition = 0
	Conductor.Offset = 0
	
	Conductor.SafeFrames = 10
	Conductor.SafeZoneOffset = ((Conductor.SafeFrames / 60) * 1000)
	
	Conductor.BPMChangeMap = {}

	Conductor.SongSpeed = 1 --\\ For song speed
end

function Conductor.MapBPMChanges(SongData)
	--\\ We will have SongData later during JSON chart parsing.
	Conductor.BPMChangeMap = {}
	
	local CurBPM = SongData.bpm
	local TotalSteps = 0
	local TotalPos = 0
	
	--\\ Loop through all the sections in the song, and check if that section requires a BPM change.
	for i = 1, #SongData.notes, 1 do --\\ The JSON files are named really confusingly.
		local CurrentSection = SongData.notes[i]
		if CurrentSection.changeBPM and (CurrentSection.bpm ~= CurBPM) then
			CurBPM = CurrentSection.bpm
			
			local BPMChangeEvent = { --\\ BPM change event.
				StepTime = TotalSteps;
				SongTime = TotalPos;
				BPM = CurBPM;
			}
			
			table.insert(Conductor.BPMChangeMap, BPMChangeEvent) --\\ Add to the BPM change map (table containing of all the changes)
		end
		
		--\\ Update the total steps, and total song position.
		local DeltaSteps = CurrentSection.lengthInSteps
		TotalSteps += DeltaSteps
		TotalPos += ((60 / CurBPM) * 1000 / 4) * DeltaSteps
	end
	
	print("new BPM map BUDDY", Conductor.BPMChangeMap) --\\ I had to look trace() up lol. It turns out it's kinda like print().
end

function Conductor.ChangeBPM(NewBPM)
	Conductor.BPM = NewBPM --\\ Set new bpm
	
	--\\ Recalculate Crochet and StepCrochet using the new BPM.
	Conductor.Crochet = ((60 / Conductor.BPM) * 1000)
	Conductor.StepCrochet = (Conductor.Crochet / 4)
end

return Conductor

Nice, now that the Conductor is finished, now let’s move on to MusicBeatState.
(Couldn’t show the Haxe one due to character limit)
We will only need the functions that manage the beat and step.
Here is my Lua transcription:

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Source = ReplicatedStorage:WaitForChild("Source")

local Conductor = require(Source.lib.Conductor)

local MusicBeatState = {}
MusicBeatState.__index = MusicBeatState

local BeatHitEvents = {}
local StepHitEvents = {}

function MusicBeatState.Initialize(SongData)
	--\\ Initialize function like the Conductor.
	MusicBeatState.LastBeat = 0
	MusicBeatState.LastStep = 0
	
	MusicBeatState.CurStep = 0
	MusicBeatState.CurBeat = 0
	
	MusicBeatState.LastChange = {
		StepTime = 0,
		SongTime = 0,
		BPM = SongData.bpm
	}
end

function MusicBeatState.OnBeatHit(func)
	--\\ kinda like RbxScriptSignal:Connect(function), but I didn't want to use bindable events.
	--\\ I know there is probably a better way to do this, so change at will.
	local newConnection = {}
	setmetatable(newConnection,MusicBeatState)
	
	newConnection.func = func --\\ function
	newConnection.t = "beat" --\\ name
	newConnection.i = (#BeatHitEvents + 1) --\\ index
	
	table.insert(BeatHitEvents,newConnection.func)
	
	return newConnection
end

function MusicBeatState.OnStepHit(func)
	--\\ kinda like RbxScriptSignal:Connect(function), but I didn't want to use bindable events.
	--\\ I know there is probably a better way to do this, so change at will.
	local newConnection = {}
	setmetatable(newConnection,MusicBeatState)

	newConnection.func = func --\\ function
	newConnection.t = "step" --\\ name
	newConnection.i = (#StepHitEvents + 1) --\\ index

	table.insert(StepHitEvents,newConnection.func)

	return newConnection
end

function MusicBeatState:Destroy()
	--\\ Cleanup for the event stuff (why is it not called disconnect? idk)
	if self.func then
		self.func = nil
	end
	
	if self.t == "beat" then
		table.remove(BeatHitEvents, self.i)
	end
	if self.t == "step" then
		table.remove(StepHitEvents, self.i)
	end
end

function MusicBeatState.UpdateCurStep()
	--\\ Updates the current step
	for i = 1,#Conductor.BPMChangeMap,1 do
		if (Conductor.SongPosition >= Conductor.BPMChangeMap[i].SongTime) then
			MusicBeatState.LastChange = Conductor.BPMChangeMap[i]
			break
		end
	end
	MusicBeatState.CurStep = MusicBeatState.LastChange.StepTime + math.floor((Conductor.SongPosition - MusicBeatState.LastChange.SongTime) / Conductor.StepCrochet)
end

function MusicBeatState.UpdateBeat()
	MusicBeatState.CurBeat = math.floor(MusicBeatState.CurStep / 4)
end

function MusicBeatState.BeatHit()
	--\\ instead of "do literally nothing", fire all the beat hit functions
	if #BeatHitEvents > 0 then
		for i = 1,#BeatHitEvents do
			if BeatHitEvents[i] then
				BeatHitEvents[i]()
			end
		end
	end
end

function MusicBeatState.StepHit()
	--\\ fire all of the step hit functions
	if #StepHitEvents > 0 then
		for i = 1,#StepHitEvents do
			if StepHitEvents[i] then
				StepHitEvents[i]()
			end
		end
	end
	--\\ fire beat at every fourth step
	if (MusicBeatState.CurStep % 4) == 0 then
		MusicBeatState.BeatHit()
	end
end

function MusicBeatState.Update()
	--\\ Main update (to be used with RenderStep)
	local oldStep = MusicBeatState.CurStep
	
	MusicBeatState.UpdateCurStep()
	MusicBeatState.UpdateBeat()
	
	if oldStep ~= MusicBeatState.CurStep and (MusicBeatState.CurStep > 0) then
		MusicBeatState.StepHit()
	end
end

return MusicBeatState

Nice! we have tackled the main libraries!
Here is where the new modules should be placed (circled in red):
image
(Also don’t forget to place the main module too. (I forgot to circle sorry. It’s above Util.))

2. Main game module
This is going to be the longest step yet (I think).

Before we continue, we need to setup the song’s assets.
The JSON chart, and the music.
Because of Roblox’s new audio system, you will have to upload the song on your own.
I will use the song “fresh” from the original game as demonstration. I recommend using original FNF songs, otherwise you will need permissions from the song’s mod creator(s) to use it on Roblox.

Why do you not need permission for original FNF songs? If you read the “do NOT readme.txt” in the original game, it says that you have permission to do anything you want.

Ok, with that permissions stuff out of the way, let’s continue.
There are two ways to upload music.

  1. Use the Instrumental and the Voices (2 audio files)
  2. Use them combined (1 audio file)

Warning: if you download from youtube and then upload it, MAKE SURE THAT IT IS AUDIO ONLY! NOTHING EXTRA, OR ELSE IT WILL BE OFF-SYNC.

Example: you find bopeebo on youtube, and at the beginning it is “three two one go!”. that beginning part will mess up the timings.

Example of a good audio:

Notice that it goes right to the music, and it doesn’t have anything extra at the beginning or the end. That is how the audio should be.
If you have the game downloaded, you can just grab the audio from the game directly like so:
(using “fresh” as an example)
image
image
image
Luckily, the audio files are in ogg-vorbis (.ogg) format, so we should have no trouble uploading it.
image
I uploaded the audios, and made new Songs folder.
Create a Songs folder like I did, and add a “Fresh” folder with the audios that you uploaded.

Nice!
Now, let’s add the charting.
You will need the game downloaded to get the charts, or you can just use the github.

Here is the general path to the charts:
image
image
image

If you are using github, follow this path:
image
Go to assets,
then open preload,
then open data.

Find the song that you want (I’m using “fresh” for the tutorial)
and you should see this:
image
(or this for github users)

Open up the difficulty that you want (the one without a difficulty is normal)
I will choose hard difficulty. It doesn’t matter which one you choose, as they are all the same JSON format. I recommend choosing a difficulty that you can play easily, as you will need to playtest it. (although I won’t cover death features so it really doesn’t matter.)

You should see something like this:


Woah! That is some compresed JSON! No worries, as it doesn’t matter, but for reading purposes, I used a JSON beautifier to make it readable. (to see the song data stuff)

Copy it all of it and paste into an empty module inside the song folder.
image
It should look something like this:

Note on the string [==[string]==]:


We need the equal signs because the JSON has many [] square brackets inside it.

Mine looks like this because of the beautifier:

Cool! We now have all the song assets, we are ready to start coding the main module!
Here is the starting of it:

--\\ Services
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local HttpService = game:GetService("HttpService")
local RunService = game:GetService("RunService")

--\\ Folders
local Assets = ReplicatedStorage:WaitForChild("Assets")
local Source = ReplicatedStorage:WaitForChild("Source")
local Songs = ReplicatedStorage:WaitForChild("Songs")

--\\ Modules
local Util = require(Source.Util)
local XMLSprite = require(Source.class.XMLSprite)
local Conductor = require(Source.lib.Conductor)
local MusicBeatState = require(Source.lib.MusicBeatState)
local GUICreator = require(Source.lib.GUI)

--\\ Core Objects
local Camera = game:GetService("Workspace").CurrentCamera

--\\ Variable setup
--\\ This is in a function so we can reset it on command.
function Initialize()
	SongData = nil
	SongName = ""
	
	OpponentStrums = {}
	PlayerStrums = {}
end

local MAIN = {}

function MAIN.LoadSong(SongNameToLoad)
	
end

return MAIN

Now that we have the bare module setup, we can start creating the first part of the game!
ARROW SETUP
Before we can create the JSON parser, we need to create the static arrow setup, and the Note class.

Static arrow creation (called “strums”)

local function CreateStrums(PlayerNumber)
	--\\ Get note assets
	local NoteImageID = Assets.Note.ImageID.Value
	local NoteXML = require(Assets.Note.XML)

	for i = 1, 4 do --\\ loop 4 times, because 4 notes
		local NewStrum = XMLSprite.new(true) --\\ new sprite

		NewStrum.Transparency = 0

		NewStrum.Size = Vector2.new(0.7, 0.7) --\\ 0.7% size, you saw how big the orignal was lol.

		if PlayerNumber == 2 then
			NewStrum.Position = Vector2.new(
				Util.GetStrumX(i + 3),
				StrumYLine - 20
			)
		else
			NewStrum.Position = Vector2.new(
				Util.GetStrumX(i - 1),
				StrumYLine - 20
			)
		end

		NewStrum.ImageLabel.Parent = GameGUI.Game.Strums
		
		--\\ 1 = left 2 = down 3 = up 4 = right
		local Directions = {
			[1] = "left";
			[2] = "down";
			[3] = "up";
			[4] = "right";
		}
		
		--\\ Only load what we need
		NewStrum:AddByPrefix(NoteXML, NoteImageID, 0.5, 24, "static", "arrow"..string.upper(Directions[i]))
		NewStrum:AddByPrefix(NoteXML, NoteImageID, 0.5, 24, "press", Directions[i].." press")
		NewStrum:AddByPrefix(NoteXML, NoteImageID, 0.5, 24, "confirm", Directions[i].." confirm")
		
		--\\ Play static animation
		NewStrum:Play("static", true)
		
		--\\ Insert into tables
		--\\ we will need this later.
		if PlayerNumber == 1 then
			table.insert(OpponentStrums, NewStrum)
		end
		if PlayerNumber == 2 then
			table.insert(PlayerStrums, NewStrum)
		end
	end
end

Hold up, did you notice this?

Util.GetStrumX(index)

We need to create a function to get an arrow’s position by it’s index.
It’s index is organized like so:

  1. “Left”
  2. “Down”
  3. “Up”
  4. “Right”

Why is it in order left down up right?
image
(notice that the order of left down up right is from left to right on the arrow keys)
If it went: left right up down, it would be really confusing to play, as left and right would be right next to each other, so you would want to press the keys close to each other.

Ok, enough of me running my mouth on arrow layout, let’s start coding the utility function.

function Util.GetStrumX(StrumIndex)
	StrumIndex += 1 --\\ Adding one because left is equal to 0.

	--\\ For strums
	if StrumIndex > 4 then --\\ Player2
		return (Util.ScreenSize.X - ((5 - (StrumIndex - 4)) * (Util.NoteWidth)))
	end

	if StrumIndex < 5 then --\\ Player1
		return (StrumIndex * (Util.NoteWidth))
	end

	return 0
end

The reason I am setting this up to be [0, 1, 2, 3] instead of [1, 2, 3, 4] is because the original game starts at 0 for tables, so the note types start at 0 in the charts.

Oh yeah, and for the NoteWidth variable in Util, please update it to this:

NoteWidth = (166 * 0.7); --\\ all notes are scaled down by 0.7, so NoteWidth is the original pixel size of every note scaled down by 0.7.

Util should now look like this:

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Source = ReplicatedStorage:WaitForChild("Source")

local Util = {
	--\\ FNF Screen size: https://github.com/FunkinCrew/Funkin/blob/master/Project.xml
	ScreenSize = Vector2.new(1280, 720); --\\ FNF is in 720p lol
	--\\ AspectRatio = 16:9

	NoteWidth = (166 * 0.7); --\\ all notes are scaled down by 0.7, so NoteWidth is the original pixel size of every note scaled down by 0.7.

	--\\ For keybinds
	KeybindNames = {
		[1] = "Left";
		[2] = "Down";
		[3] = "Up";
		[4] = "Right";
	};

	--\\ For notes
	NoteDataNames = {
		[1] = "Left";
		[2] = "Down";
		[3] = "Up";
		[4] = "Right";
	};
}

function Util.GetXPositionForNoteData(NoteData, ManualSide, PlayerSide)
	NoteData += 1 --\\ Shift it over one for roblox

	if PlayerSide == 2 then
		--\\ Normal PlayerSide
		--\\ Shenanigans for Notes.
		NoteData -= 1 --\\ Shift it back over because we are using modulo

		if ManualSide == 2 then --\\ Player2
			return (Util.ScreenSize.X - ((5 - ((NoteData % 4) + 1)) * (Util.NoteWidth)))
		end

		if ManualSide == 1 then --\\ Player1
			return (((NoteData % 4) + 1) * (Util.NoteWidth))
		end
	else
		--\\ Flip all if PlayerSide is flipped.
		--\\ Shenanigans for Notes.
		NoteData -= 1 --\\ Shift it back over because we are using modulo

		if ManualSide == 1 then --\\ Player2
			return (Util.ScreenSize.X - ((5 - ((NoteData % 4) + 1)) * (Util.NoteWidth)))
		end

		if ManualSide == 2 then --\\ Player1
			return (((NoteData % 4) + 1) * (Util.NoteWidth))
		end
	end

	return 0
end

function Util.Lerp(start, goal, t)
	return start + (goal - start) * t
end

function Util.GetStrumX(StrumIndex)
	StrumIndex += 1

	--\\ For strums
	if StrumIndex > 4 then --\\ Player2
		--\\ get screensize-x and subtract (5-(index-4) * NoteWidth)
		--\\ it's easier to understand this after you look at the one for the opponent
		return (Util.ScreenSize.X - ((5 - (StrumIndex - 4)) * (Util.NoteWidth)))
	end

	if StrumIndex < 5 then --\\ Player1
		--\\ just the product of the index and the NoteWidth
		return (StrumIndex * (Util.NoteWidth))
	end

	return 0
end

return Util

Before we finish up the arrow stuff, we need to make a class called Note. This class is what each moving arrow will be based off.
Example:
https://streamable.com/bq8xq2
Notice the colored arrows that go up into the strums.
This is what the Note class will be handling.

Here is the Note class:

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")

local Source = ReplicatedStorage:WaitForChild("Source")
local Assets = ReplicatedStorage:WaitForChild("Assets")

local Util = require(Source.Util)
local XMLSprite = require(Source.class.XMLSprite)
local Conductor = require(Source.lib.Conductor)

local Directions = {
	[1] = "left";
	[2] = "down";
	[3] = "up";
	[4] = "right";
}

local ColorDirections = { --\\ this is here because in the XML data, notes are named by color.
	[1] = "purple";
	[2] = "blue";
	[3] = "green";
	[4] = "red";
}

local function GetTargetStrum(NoteData, Side)
	if Side == 1 then
		return Conductor.OpponentStrums[(NoteData % 4) + 1]
	end
	if Side == 2 then
		return Conductor.PlayerStrums[(NoteData % 4) + 1]
	end

	return nil
end

local Note = {}
Note.__index = Note

function Note.new(StrumTime, NoteData, MustPress, NoteType, IsHoldNote, IsHoldEnd, PrevNote)
	local NewNote = setmetatable({
		StrumTime = StrumTime;
		NoteData = NoteData;
		MustPress = MustPress;
		
		IsHoldNote = IsHoldNote;
		IsHoldEnd = IsHoldEnd;
		
		PrevNote = PrevNote;
		
		NoteType = NoteType;
		
		CanBeHeld = false;
		CanBeHit = false;
		WasGoodHit = false;
		TooLate = false;
		
		Sprite = nil;
		
		Destroyed = false;
		UpdateConnection = nil;
		CanUpdate = true;
		
		Spawned = false;
	}, Note)
	
	if NoteType == "InsertNoteTypeHere" then --\\ Adding this here so you can make custom note types EX: Tricky Halo notes.
		--\\ do something
	else 
		--\\ Default note
		local ImageID = Assets.Note.ImageID.Value
		local XML = Assets.Note.XML

		local NewSprite = XMLSprite.new(false)

		NewSprite.Position = Vector2.new(
			Util.GetXPositionForNoteData(NoteData, (MustPress and 2 or 1), 2),
			2000 --\\ Make sure it is offscreen
		)

		NewSprite.Size = Vector2.new(
			(0.7),
			(0.7)
		)
		
		--\\ Only load what we need!
		local Name = Directions[((NoteData % 4) + 1)]
		local Color = ColorDirections[(NoteData % 4) + 1]
		
		if Name == "left" then --\\ We have to do these shenanigans because of a typo the FNF developers made :|
			if IsHoldNote then
				if IsHoldEnd then
					NewSprite:AddByPrefix(XML, ImageID, 0.5, 24, Name.." end", "pruple end hold")
				else
					NewSprite:AddByPrefix(XML, ImageID, 0.5, 24, Name.." hold", Color.." hold piece")
				end
			else
				NewSprite:AddByPrefix(XML, ImageID, 0.5, 24, Name, Color)
			end
		else
			if IsHoldNote then
				if IsHoldEnd then
					NewSprite:AddByPrefix(XML, ImageID, 0.5, 24, Name.." end", Color.." hold end")
				else
					NewSprite:AddByPrefix(XML, ImageID, 0.5, 24, Name.." hold", Color.." hold piece")
				end
			else
				NewSprite:AddByPrefix(XML, ImageID, 0.5, 24, Name, Color)
			end
		end

		NewNote.Sprite = NewSprite
	end
	
	--\\ Create update connection
	NewNote.UpdateConnection = RunService.RenderStepped:Connect(function(DeltaTime)
		NewNote:Update(DeltaTime)
		NewNote.Sprite:Update(DeltaTime)
	end)
	
	NewNote.Sprite.ImageLabel.ZIndex = 3
	
	if IsHoldNote then
		NewNote.Sprite.ImageLabel.ZIndex = 2
		NewNote.Sprite.Transparency = 0.5
		
		if IsHoldEnd then
			NewNote.Sprite:Play(Directions[((NoteData % 4) + 1)].." end", true)
		else
			NewNote.Sprite:Play(Directions[((NoteData % 4) + 1)].." hold", true)
		end
	else
		NewNote.Sprite:Play(Directions[((NoteData % 4) + 1)], true)
	end
	
	return NewNote
end

function Note:Update(DeltaTime)
	if self.CanUpdate and self.Spawned then
		if self.MustPress then
			--\\ Check if note is within hit time
			if self.StrumTime > (Conductor.SongPosition - (Conductor.SafeZoneOffset * 1)) and self.StrumTime < (Conductor.SongPosition + (Conductor.SafeZoneOffset * 0.5)) then
				self.CanBeHit = true
			else
				self.CanBeHit = false
			end
			
			if self.StrumTime <= Conductor.SongPosition then
				self.CanBeHeld = true
			else
				self.CanBeHeld = false
			end
			
			--\\ Check if note is not too late
			if self.StrumTime < (Conductor.SongPosition - Conductor.SafeZoneOffset) and (not self.WasGoodHit) then
				self.TooLate = true
			end
		else
			--\\ Opponent note
			self.CanBeHit = false
			
			--\\ Opponent auto hit
			if self.StrumTime <= Conductor.SongPosition then
				self.WasGoodHit = true
			end
		end
		
		self:GameUpdate(DeltaTime)
	end
end

function Note:GameUpdate(DeltaTime)
	--\\ Update note position
	--\\ Get note side
	local NoteSide
	if self.MustPress then
		NoteSide = 2
	else
		NoteSide = 1
	end
	
	--\\ Set the sustain size
	local FNF_SizeScale = 1
	if self.IsHoldNote and (not self.IsHoldEnd) then
		FNF_SizeScale = ((Conductor.StepCrochet / Conductor.SongSpeed) / 100 * 1.5 * (Conductor.SongSpeed))
	end

	if self.IsHoldEnd then
		self.Sprite.Size = Vector2.new(
			0.7,
			1
		)
	else
		self.Sprite.Size = Vector2.new(
			0.7,
			(0.7 * FNF_SizeScale)
		)
	end
	
	--\\ Get the strum that the note is heading for.
	local TargetStrum = GetTargetStrum(self.NoteData, NoteSide)
	
	--\\ set the Y position
	local y = (TargetStrum.Position.Y - (0.45 * (Conductor.SongPosition - self.StrumTime) * Conductor.SongSpeed))
	local x = self.Sprite.Position.X --\\ X stays the same
	
	--\\ Update the new positions
	self.Sprite.Position = Vector2.new(x, y)
end

function Note:Destroy()
	self.CanUpdate = false
	self.Destroyed = true
	
	if self.UpdateConnection then
		self.UpdateConnection:Disconnect()
	end
	
	self.Sprite:Destroy()
end

return Note

Ok, here is where the new Note class goes:
image

Notice the PlayerStrums and the OpponentStrums are being referenced from the Conductor?
We will have to update the Conductor to contain those variables too. See, the Conductor is also used as a variable storage for other scripts to access.

Updated Conductor:

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Source = ReplicatedStorage:WaitForChild("Source")

local Util = require(Source.Util)

local Conductor = {}

function Conductor.Initialize()
	--\\ Just put all the variables in an initialize function so we can reset the conductor.
	Conductor.BPM = 100
	Conductor.Crochet = ((60 / Conductor.BPM) * 1000)
	Conductor.StepCrochet = (Conductor.Crochet / 4)
	Conductor.SongPosition = 0
	Conductor.LastSongPosition = 0
	Conductor.Offset = 0
	
	Conductor.SafeFrames = 10
	Conductor.SafeZoneOffset = ((Conductor.SafeFrames / 60) * 1000)
	
	Conductor.BPMChangeMap = {}
	
	--\\ The conductor is also used to store game data that can be accessed by all scripts.
	Conductor.SongSpeed = 1
	Conductor.OpponentStrums = {}
	Conductor.PlayerStrums = {}
end

function Conductor.MapBPMChanges(SongData)
	Conductor.BPMChangeMap = {}
	
	local CurBPM = SongData.bpm
	local TotalSteps = 0
	local TotalPos = 0
	
	--\\ Loop through all the sections in the song, and check if that section requires a BPM change.
	for i = 1, #SongData.notes, 1 do --\\ The JSON files are named really confusingly.
		local CurrentSection = SongData.notes[i]
		if CurrentSection.changeBPM and (CurrentSection.bpm ~= CurBPM) then
			CurBPM = CurrentSection.bpm
			
			local BPMChangeEvent = { --\\ BPM change event.
				StepTime = TotalSteps;
				SongTime = TotalPos;
				BPM = CurBPM;
			}
			
			table.insert(Conductor.BPMChangeMap, BPMChangeEvent) --\\ Add to the BPM change map (table containing of all the changes)
		end
		
		--\\ Update the total steps, and total song position.
		local DeltaSteps = CurrentSection.lengthInSteps
		TotalSteps += DeltaSteps
		TotalPos += ((60 / CurBPM) * 1000 / 4) * DeltaSteps
	end
	
	print("new BPM map BUDDY", Conductor.BPMChangeMap) --\\ I had to look trace() up lol. It turns out it's kinda like print().
end

function Conductor.ChangeBPM(NewBPM)
	Conductor.BPM = NewBPM --\\ Set new bpm
	
	--\\ Recalculate Crochet and StepCrochet.
	Conductor.Crochet = ((60 / Conductor.BPM) * 1000)
	Conductor.StepCrochet = (Conductor.Crochet / 4)
end

return Conductor

Nice!

Now that we have it all set up for the arrow spawning, we can now create the JSON chart parser!
JSON CHART PARSER

--\\ Services
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local HttpService = game:GetService("HttpService")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")

--\\ Folders
local Assets = ReplicatedStorage:WaitForChild("Assets")
local Source = ReplicatedStorage:WaitForChild("Source")
local Songs = ReplicatedStorage:WaitForChild("Songs")

--\\ Modules
local Util = require(Source.Util)
local XMLSprite = require(Source.class.XMLSprite)
local NoteClass = require(Source.class.Note)
local Conductor = require(Source.lib.Conductor)
local MusicBeatState = require(Source.lib.MusicBeatState)
local GUICreator = require(Source.lib.GUI)

--\\ Core Objects
local Camera = game:GetService("Workspace").CurrentCamera

--\\ Constants
local StrumYLine = 100

local Keybinds = { --\\ Change keybinds at will!
	[1] = Enum.KeyCode.D;
	[2] = Enum.KeyCode.F;
	[3] = Enum.KeyCode.J;
	[4] = Enum.KeyCode.K;
}

--\\ Variable setup
--\\ This is in a function so we can reset it on command.
local function Initialize()
	SongData = nil
	SongName = ""
	LOADED = false
	STARTED_SONG = false
	
	RS_CONNECTION = nil
	
	INST = nil
	VOICES = nil
	
	UIScale = 1
	FOVScale = 70
	
	GameGUI = nil
	
	JSON_FILE = nil
	
	OpponentStrums = {}
	PlayerStrums = {}
	
	UnspawnedNotes = {}
	NotesInGame = {}
	
	SolidNotes = {} --\\ Keeps track of player notes
	
	--\\ Input stuff
	InputBeganConnection = nil
	InputEndedConnection = nil
	
	BindsPressed = {
		[1] = false;
		[2] = false;
		[3] = false;
		[4] = false;
	}
	
	BindWasChecked = { --\\ This is to prevent people holding down keys and hitting all the notes.
		[1] = false;
		[2] = false;
		[3] = false;
		[4] = false;
	}
end

local function EndGame() --\\ Put whatever cleanup you want in here
	if RS_CONNECTION then
		RS_CONNECTION:Disconnect()
	end
	
	if InputBeganConnection then
		InputBeganConnection:Disconnect()
	end
	if InputEndedConnection then
		InputEndedConnection:Disconnect()
	end
end

local function StartCountdown() --\\ This will run when the countdown should be started.
	
end

local function CreateStrums(PlayerNumber)
	--\\ Get note assets
	local NoteImageID = Assets.Note.ImageID.Value
	local NoteXML = require(Assets.Note.XML)

	for i = 1, 4 do --\\ loop 4 times, because 4 notes
		local NewStrum = XMLSprite.new(true) --\\ new sprite

		NewStrum.Transparency = 0

		NewStrum.Size = Vector2.new(0.7, 0.7) --\\ 0.7% size, you saw how big the orignal was lol.

		if PlayerNumber == 2 then
			NewStrum.Position = Vector2.new(
				Util.GetStrumX(i + 3),
				StrumYLine - 20
			)
		else
			NewStrum.Position = Vector2.new(
				Util.GetStrumX(i - 1),
				StrumYLine - 20
			)
		end

		NewStrum.ImageLabel.Parent = GameGUI.Game.Strums
		
		--\\ 1 = left 2 = down 3 = up 4 = right
		local Directions = {
			[1] = "left";
			[2] = "down";
			[3] = "up";
			[4] = "right";
		}
		
		--\\ Only load what we need
		NewStrum:AddByPrefix(NoteXML, NoteImageID, 0.5, 24, "static", "arrow"..string.upper(Directions[i]))
		NewStrum:AddByPrefix(NoteXML, NoteImageID, 0.5, 24, "press", Directions[i].." press")
		NewStrum:AddByPrefix(NoteXML, NoteImageID, 0.5, 24, "confirm", Directions[i].." confirm")
		
		--\\ Play static animation
		NewStrum:Play("static", true)
		
		--\\ Insert into tables
		--\\ we will need this later.
		if PlayerNumber == 1 then
			table.insert(OpponentStrums, NewStrum)
		end
		if PlayerNumber == 2 then
			table.insert(PlayerStrums, NewStrum)
		end
	end
end

local MAIN = {}

function MAIN.LoadSong(SongNameToLoad)
	Initialize()
	
	--\\ Get the song folder and assets, don't run if stuff doesn't exist.
	local SongFolder = Songs:FindFirstChild(SongNameToLoad)
	
	if not SongFolder then
		warn(SongNameToLoad.." was not found in ReplicatedStorage.Songs")
		return
	end
	
	local InstOnly = false
	--\\ If you are using only 1 audio file, just call it Inst and set the variable above to true
	INST = SongFolder:FindFirstChild("Inst")
	VOICES = SongFolder:FindFirstChild("Voices")
	JSON_FILE = SongFolder:FindFirstChild("JSON")
	
	if InstOnly then
		if not (INST and JSON_FILE) then
			warn(SongNameToLoad.." does not have inst and JSON file!")
			return
		end
	else
		if not (INST and VOICES and JSON_FILE) then
			warn(SongNameToLoad.." does not have inst, voices, and JSON file!")
			return
		end
	end
	
	--\\ Do I really need this pcall()? Or am I being overly error avoiding?
	--\\ This will error if the JSON is malformed or something though.
	local success, err = pcall(function()
		SongData = HttpService:JSONDecode(require(JSON_FILE))
	end)
	
	--\\ Check if it was parsed
	if not success then
		warn("Unable to parse JSON!")
		return
	end
	
	SongData = SongData.song
	
	--\\ Get GameGUI
	GameGUI = GUICreator.GameGUI(5)
	GameGUI.Parent = Players.LocalPlayer:WaitForChild("PlayerGui") --\\ Parent to playergui
	
	--\\ Setup libraries
	Conductor.Initialize()
	Conductor.ChangeBPM(SongData.bpm) --\\ Set initial bpm
	
	--\\ Create player1 (opponent) and player2 (player) strums
	CreateStrums(1)
	CreateStrums(2)
	
	--\\ Update Conductor variables
	Conductor.OpponentStrums = OpponentStrums
	Conductor.PlayerStrums = PlayerStrums
	
	MusicBeatState.Initialize(SongData)
	
	--\\ Variable setup
	for i = 1, 4 do
		table.insert(SolidNotes, {})
	end
	
	--\\ Input handlers (these will be used later)
	InputBeganConnection = UserInputService.InputBegan:Connect(function(Input, GameProcessedEvent)
		if GameProcessedEvent then return end
		for i = 1, #Keybinds do
			local Bind = Keybinds[i]
			
			if Input.KeyCode == Bind then
				BindsPressed[i] = true
			end
		end
	end)
	
	InputEndedConnection = UserInputService.InputEnded:Connect(function(Input, GameProcessedEvent)
		if GameProcessedEvent then return end

		for i = 1, #Keybinds do
			local Bind = Keybinds[i]

			if Input.KeyCode == Bind then
				BindsPressed[i] = false
				PlayerStrums[i]:Play("static", true)
				BindWasChecked[i] = false
			end
		end
	end)
	
	--\\ Load song
	local NoteData = SongData.notes
	Conductor.SongSpeed = SongData.speed or 1 --\\ Set speed
	
	local NoteLoops = 0
	local SectionLoops = 0
	
	local StartTime = os.clock() --\\ This is used to measure how long it takes to load the song.

	for i = 1, #NoteData do
		local Section = NoteData[i]

		if Section.sectionNotes then
			for v = 1, #Section.sectionNotes do
				local Note = Section.sectionNotes[v]
				
				--\\ StrumTime is the time the note should be hit in (ms)
				local StrumTime = Note[1]
				local NoteData = Note[2] --\\ Note data is an integer from 0 - 7. It determines which note type it is EX: 0 == "left" etc. etc.
				
				local GottaHitNote = Section.mustHitSection --\\ Self explanatory

				if NoteData > 3 then --\\ above 3 means it is the other character's notes. if musthit is false and the notedata is 7, then it is the player's note and vice versa
					GottaHitNote = (not Section.mustHitSection)
				end
				
				--\\ Used to detect sustains in the original source code. I'm not using it, but I'm keeping it here in case something needs it later on.
				local OldNote
				if #UnspawnedNotes > 0 then
					OldNote = UnspawnedNotes[#UnspawnedNotes - 1]
				else
					OldNote = nil
				end

				local NoteType = "" --\\ use this to implement custom note types if you want.

				local NewNote = NoteClass.new(StrumTime, NoteData, GottaHitNote, NoteType, false, false, OldNote)
				
				--\\ add to unspawned notes.
				table.insert(UnspawnedNotes, NewNote)

				--\\ Create hold parts
				local SustainLength = Note[3]
				SustainLength /= Conductor.StepCrochet

				local FloorSustain = (math.floor(SustainLength) * (Conductor.SongSpeed))

				if FloorSustain > 0 then
					for length = 1, FloorSustain, 1 do
						--\\ This is calculating the sustain chunk timings. I don't actually know how this works. I guess that is a question for ninjamuffin99 lol.
						local SustainStrumTime = (StrumTime + ((Conductor.StepCrochet / (Conductor.SongSpeed)) * length))
						--\\ Get a true/false variable depending on if this is the last one or not.
						local IsEnd = ((length == FloorSustain) or (length + 1) > FloorSustain)
						--\\ get PrevNote (previous note)
						OldNote = UnspawnedNotes[#UnspawnedNotes - 1]
						--\\ create new sustain note
						local SustainNote = NoteClass.new(SustainStrumTime, NoteData, GottaHitNote, NoteType, true, IsEnd, OldNote)
						
						--\\ add to unspawned notes
						table.insert(UnspawnedNotes, SustainNote)
					end
				end
			end

			--\\ Yield a little every 8 loops to avoid lag
			NoteLoops += 1
			if (NoteLoops % 8) == 0 then
				RunService.Heartbeat:Wait()
			end
		end

		--\\ Yield a little every 8 loops to avoid lag
		SectionLoops += 1
		if (SectionLoops % 8) == 0 then
			RunService.Heartbeat:Wait()
		end
	end
	
	--\\ Sort from least StrumTime to greatest StrumTime
	--\\ This is super important because the notes in the chart are not in order!
	table.sort(UnspawnedNotes, function(a, b)
		return a.StrumTime < b.StrumTime
	end)
	
	print(("Loading finished! elapsed time: %s"):format(os.clock() - StartTime))
	LOADED = true
end

Whew, that was a big chunk. We are almost there to testing!
Let’s start by changing a few things, then we can test the game.
First, let’s change some stuff in the XMLSprite; Specifically line 21.

NewImageLabel.BackgroundTransparency = 0.5 --\\ for debug, it shows the sprite's size.

We need to change this value to 1 like so:

NewImageLabel.BackgroundTransparency = 1 --\\ for debug, it shows the sprite's size.

(Here is updated code in case you can’t find it.)

local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Source = ReplicatedStorage:WaitForChild("Source")

local Util = require(Source.Util)
local AnimationManager = require(Source.class.AnimationManager)

local XMLSprite = {}
XMLSprite.__index = XMLSprite

function XMLSprite.new(AutomaticUpdate)
	--\\ Create new ImageLabel for the sprite to use
	local NewImageLabel = Instance.new("ImageLabel")
	NewImageLabel.AnchorPoint = Vector2.new(0.5, 0.5)
	NewImageLabel.Position = UDim2.fromScale(0.5, 0.5)
	NewImageLabel.Size = UDim2.fromOffset(100, 100)
	NewImageLabel.ScaleType = Enum.ScaleType.Stretch
	NewImageLabel.Name = "XMLSprite_ImageLabel"

	NewImageLabel.BackgroundTransparency = 1 --\\ for debug, it shows the sprite's size.

	--\\ Create new sprite object
	local NewSprite = setmetatable({
		Position = Vector2.new(0, 0);
		Size = Vector2.new(1, 1);

		AnimationManager = AnimationManager.new();
		
		ImageLabel = NewImageLabel;

		Destroyed = false;
		CanUpdate = true;
		UpdateConnection = nil;
	}, XMLSprite)

	--\\ Update the sprite
	if AutomaticUpdate then
		NewSprite.UpdateConnection = RunService.RenderStepped:Connect(function(DeltaTime)
			NewSprite:Update(DeltaTime)
		end)
	end

	return NewSprite
end


function XMLSprite:AddByPrefix(XML, ImageID, ScaleFactor, Framerate, AnimationName, AnimationPrefix) --\\ ScaleFactor added!
	self.AnimationManager:AddByPrefix(XML, ImageID, ScaleFactor, Framerate, AnimationName, AnimationPrefix)
end

function XMLSprite:Play(AnimationName, Override)
	self.AnimationManager:Play(self.ImageLabel, AnimationName, Override)
end

function XMLSprite:StopAll()
	self.AnimationManager:StopAll()
end

function XMLSprite:Update(DeltaTime)
	if self.CanUpdate then
		self.AnimationManager.Position = self.Position
		self.AnimationManager.Size = self.Size
		
		self.AnimationManager:Update(DeltaTime, self.ImageLabel)
	end
end

function XMLSprite:Destroy()
	self.CanUpdate = false
	self.Destroyed = true

	if self.UpdateConnection then
		self.UpdateConnection:Disconnect()
	end

	self.ImageLabel:Destroy()
end

return XMLSprite

Next, we need to change the LocalScript we used for testing to use the Main module.
You can find it chilling in ReplicatedFirst.
image
Here is the updated code for Game:

if not game:IsLoaded() then
	game.Loaded:Wait()
end

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

local Source = ReplicatedStorage:WaitForChild("Source")
local Assets = ReplicatedStorage:WaitForChild("Assets")

local Main = require(Source.Main)

task.wait(4)
Main.LoadSong("Fresh") --\\ If you are using a different song, change the name to the song that you chose. (case sensitive)

Wow! that was really short! This is because most of the code will reside in the Main module.

One last thing before we test,
Set CharacterAutoLoads to false like so:
image

This way, we don’t have to deal with our character.

Ok, lets try it out!
https://streamable.com/pt8lac
Yes!! the strums appear!
Alright, now lets create the gameplay.

First, we need one last module.
Yes, I know there are so many in this tutorial, but I assure you this is the last one.
I call it ScoreManager. It basically calculates your rating depending on the millisecond offset of when you hit notes. (Sick, Good, Bad, Trash).
Here it is:

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Source = ReplicatedStorage:WaitForChild("Source")

local Conductor = require(Source.lib.Conductor)

local Ratings = {
	[1] = {
		Name = "Sick";
		RatingMod = 1;
		RatingWindow = 45; --\\ Milliseconds
		Score = 350;
	};
	[2] = {
		Name = "Good";
		RatingMod = 0.7;
		RatingWindow = 90;--\\ Milliseconds
		Score = 200;
	};
	[3] = {
		Name = "Bad";
		RatingMod = 0.4;
		RatingWindow = 135; --\\ Milliseconds
		Score = 100;
	};
	[4] = {
		Name = "Trash";
		RatingMod = 0;
		RatingWindow = 200; --\\ Milliseconds
		Score = 50;
	};
}

local ScoreManager = {}

function ScoreManager.GetScoreAndJudgement(TimeDifference)
	--\\ Skips the last one ("Trash")
	--\\ Loop through all the ratings, and return the first one that is >= to the MS offset.
	for i = 1, (#Ratings - 1) do
		local Rating = Ratings[i]
		local RatingWindow = Rating.RatingWindow
		
		if math.abs(TimeDifference) <= RatingWindow then
			return Ratings[i]
		end
	end
	
	--\\ If no ratings matched, return "Trash".
	return Ratings[#Ratings]
end

return ScoreManager

Ok nice! Now, let’s add that to the main module:

--\\ Modules
local Util = require(Source.Util)
local XMLSprite = require(Source.class.XMLSprite)
local NoteClass = require(Source.class.Note)
local Conductor = require(Source.lib.Conductor)
local MusicBeatState = require(Source.lib.MusicBeatState)
local GUICreator = require(Source.lib.GUI)
local ScoreManager = require(Source.lib.ScoreManager) --\\ The new ScoreManager!

Alright, let’s start creating the gameplay.

function MAIN.PlaySong()
	local StartedCountdown = false
	
	if not LOADED then
		warn("Please load the song first before playing.")
	end
	
	--\\ Functions
	local function GoodNoteHit(Note, SI)
		--\\ SI stands for SolidIndex (index in SolidNotes)
		local Rating = ScoreManager.GetScoreAndJudgement(Note.StrumTime - Conductor.SongPosition)
		print(Rating.Name) --\\ You can make this a GUI if you want lol.
		
		Note:Destroy()
		table.remove(SolidNotes[SI], 1)
		
		PlayerStrums[(Note.NoteData % 4) + 1]:Play("confirm", true)
	end
	
	--\\ Pre-song setup
	Conductor.SongPosition = -5000 --\\ Set the SongPosition to five seconds before the actual song starts (this way we can spawn notes early and have time for countdown.)
		
	RS_CONNECTION = RunService.RenderStepped:Connect(function(DeltaTime)
		--\\ Update MBS and conductor.
		MusicBeatState.Update()
		Conductor.SongPosition += (DeltaTime * 1000) --\\ Convert DeltaTime to milliseconds
		
		--\\ Lerp UI Zoom and Camera FOV Zoom
		local LerpFactor = 0.05
		UIScale = Util.Lerp(UIScale, 1, LerpFactor)
		FOVScale = Util.Lerp(FOVScale, 70, LerpFactor)
		GameGUI.Game.UIScale.Scale = UIScale
		Camera.FieldOfView = FOVScale
		
		
		--\\ Spawn in notes
		if #UnspawnedNotes > 0 then
			local AmountToRemove = 0
			
			for i = 1, #UnspawnedNotes do
				local NewNote = UnspawnedNotes[i]
				--\\ Stop when notes are spawned 2000 milliseconds before they have to be hit.
				if not ((UnspawnedNotes[i].StrumTime - Conductor.SongPosition) < 2000) then
					break
				end

				NewNote.Spawned = true --\\ Tell the note that it is spawned.

				--\\ IMPORTANT: Parent new note to GameFrame, otherwise we won't see it.
				NewNote.Sprite.ImageLabel.Parent = GameGUI.Game.Notes
				
				--\\ Add to NotesInGame for us to reference later.
				table.insert(NotesInGame, NewNote)
				if (not NewNote.IsHoldNote) and NewNote.MustPress then
					--\\ If we have to hit this note, and it is NOT a hold note, we add to SolidNotes for hit detection.
					table.insert(SolidNotes[(NewNote.NoteData % 4) + 1], NewNote)
				end

				AmountToRemove += 1
			end

			--\\ Delete spawned in notes
			for i = 1, AmountToRemove do
				table.remove(UnspawnedNotes, 1)
			end
		end
		
		if Conductor.SongPosition >= 0 and (not STARTED_SONG) then
			STARTED_SONG = true
			--\\ Start at time 0.
			--\\ Start music
			if INST then
				INST:Play()
			end
			if VOICES then
				VOICES:Play()
			end
			
			print("Song started!")
		end
		
		if Conductor.SongPosition >= -3000 and (not StartedCountdown) then
			--\\ Countdown start
			StartedCountdown = true
			
			StartCountdown()
		end
		
		if Conductor.SongPosition >= ((INST.TimeLength * 1000) + 100) then --\\ end the song at the time of the instrumental plus 0.1 seconds.
			EndGame()
			print("Song over!")
		end
		
		if STARTED_SONG then
			--\\ Update Notes
			for i = 1, #NotesInGame do
				local Note = NotesInGame[i]
				
				if Note then
					if not Note.MustPress then --\\ Opponent note auto hit
						if Note.WasGoodHit then
							Note:Destroy() --\\ Delete the note because the opponent just hit it.
						end
					end
					
					if Note.MustPress and Note.IsHoldNote and Note.CanBeHeld then
						--\\ Only handle hit if we are pressing down on the keybind.
						if BindsPressed[(Note.NoteData % 4) + 1] then
							Note:Destroy()
							PlayerStrums[(Note.NoteData % 4) + 1]:Play("confirm", true)
						end
					end
					
					if Note.MustPress and Note.TooLate then
						--\\ Player note auto-delete
						Note:Destroy()
					end
				end
			end
			
			--\\ Clean up any deleted notes
			for i = 1, #NotesInGame do
				local Note = NotesInGame[i]
				
				if Note then
					if Note.Destroyed then
						table.remove(NotesInGame, i)
					end
				end
			end
			
			--\\ Clean up deleted notes in SolidNotes.
			for i = 1, #SolidNotes do
				local NoteList = SolidNotes[i]

				for v = 1, #NoteList do
					local Note = NoteList[v]

					if Note then
						if Note.MustPress and Note.TooLate then
							--\\ Player note auto-delete
							Note:Destroy()
						end
						
						if Note.Destroyed then
							table.remove(NoteList, v)
						end
					end
				end
			end

			--\\ hit detection
			--\\ Sort first
			for i = 1, #SolidNotes do
				local NoteList = SolidNotes[i]
				
				table.sort(NoteList, function(a, b)
					return a.StrumTime < b.StrumTime
				end)
			end
			
			--\\ Loop through each note type
			for i = 1,4 do
				if BindsPressed[i] then --\\ is bind pressed
					if (not BindWasChecked[i]) then --\\ bind was not already pressed
						BindWasChecked[i] = true
						local HittableNotes = {} --\\ get hittable notes
						for i,note in pairs(SolidNotes[i]) do
							if (not note.TooLate) and (not note.IsSustainNote) and note.CanBeHit and (not note.WasGoodHit) then
								table.insert(HittableNotes,note)
							end
						end

						table.sort(HittableNotes,function(a,b) --\\ sort
							return (a.StrumTime < b.StrumTime)
						end)

						if #HittableNotes > 0 then
							local TopNote = HittableNotes[1] --\\ get the first note in HittableNotes
							
							--\\ YES! You hit a note!
							PlayerStrums[i]:Play("confirm",true)
							TopNote.WasGoodHit = true
							GoodNoteHit(TopNote, i)
						else
							--\\ You either missed a note, or you just pressed a key when there were no notes incoming.
							PlayerStrums[i]:Play("press",true)
						end
					end
				end
			end
		end
	end)
	
	--\\ Fires every beat
	MusicBeatState.OnBeatHit(function()
		--\\ Zoom camera every 4 beats but, only after the song starts!
		if ((MusicBeatState.CurBeat % 4) == 0) and STARTED_SONG then
			UIScale += 0.02
			FOVScale -= 0.6
		end
	end)
end

Cool!, Last thing before testing, we need to update the EndGame() and StartCountdown() functions!
Here is the updated ones:

local function EndGame() --\\ Put whatever cleanup you want in here
	if RS_CONNECTION then
		RS_CONNECTION:Disconnect()
	end
	
	if InputBeganConnection then
		InputBeganConnection:Disconnect()
	end
	if InputEndedConnection then
		InputEndedConnection:Disconnect()
	end
	
	if GameGUI then
		GameGUI:Destroy()
	end
end

local function StartCountdown() --\\ This will run when the countdown should be started.
	coroutine.wrap(function() --\\ We don't want this to yield!
		local Counts = 4 --\\ 3, 2, 1, GO!
		
		task.wait(3 - ((60 / Conductor.BPM) * 4)) --\\ We have to wait 60/bpm 4 times, so we subtract that from 3 to get the starting wait time.
		
		local Countdowns = {
			[1] = "3";
			[2] = "2";
			[3] = "1";
			[4] = "Go!";
		}
		
		for i = 1, #Countdowns do
			local Countdown = Countdowns[i]
			
			print(Countdown) --\\ For the sake of simplicity, I am using the print(). You can implement a GUI.
			
			task.wait((60 / Conductor.BPM)) --\\ wait 60/bpm so it's in beat sync.
		end
	end)()
end

One last thing, we need to update the Game LocalScript.
We need to add the Main.Play() under the Main.LoadSong(SongName).

if not game:IsLoaded() then
	game.Loaded:Wait()
end

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

local Source = ReplicatedStorage:WaitForChild("Source")
local Assets = ReplicatedStorage:WaitForChild("Assets")

local Main = require(Source.Main)

task.wait(4)
Main.LoadSong("Fresh") --\\ If you are using a different song, change the name to the song that you chose. (case sensitive)
Main.PlaySong() --\\ Play the song!

And we get:
https://streamable.com/unmtvr
:tada::tada::tada::tada:

Great job not falling asleep to me!
I’m sorry that I didn’t get to the health bar, but it should be easy to implement on your own. As of now you should have a FNF game framework. This is made to be built apon, so you can add features at will! EX: Note splashes, Ghost tapping, Opponent strum light up, etc.
Also, you remember the NoteType variable in the song loader?
That will allow you to create custom notes if you wanted to.

I hope you enjoyed this tutorial.

Please by all means tell me if I missed something.

I’ll try to answer questions as best as I can.

EDIT: Util was missing values, so incase you are missing values, here is the full folder of libraries:

Thanks for reading!

29 Likes

Glad part 2 is finally out! Though, Are you going to make the place you did the tutorial in uncopylocked? Copy and pasting all of those modules from part 1 and part 2 seems rather tiresome.

Thanks!

2 Likes

If it becomes really annoying to everyone who reads it, I guess ill do that, but for now I kinda want people to try to actually read the modules instead of just copying a place. I get what you are saying though, there are so many things, so I can see how tiresome it is lol.

4 Likes

Oh, speaking of… There’s some missing functions in the Util module that you used in this post, Those mainly being Util.Lerp and Util.GetGetXPositionForNoteData, those are the 2 functions I found missing from the Util module, or I’m just blind.

1 Like

As @jamesderrman was saying the tutorial is pretty confusing at least for me and Util.Lerp and Util.GetGetXPositionForNoteData are missing from the Util module

1 Like

Honestly not bad of a tutorial!
It’s really similar to the process I’ve had to go thru the development of my FNF engine, so +1 from me!
Only issue is the sprite not supporting proper centering when it relates to other sprites like the character sprites.

3 Likes

Wow, thanks!

Oh yeah, and about the sprite positioning thing, that is probably something that I will work on and ill put an update or something. I think that it has something to do with the XML values that have more data (like 4 extra values), but ill have to test.

(probably updates in a new post because I hit the 50000 limit exactly.)

EDIT: Wow, I tried your engine, and the gameplay beats every single FNF game on Roblox by a mile! Props to you for getting it that good!

2 Likes

Oh no, Very sorry, I’ll fix it ASAP!

1 Like

Sorry about that! I probably deleted it on accident because I was hitting the 50000 character limit
Here is Util:

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Source = ReplicatedStorage:WaitForChild("Source")

local Util = {
	--\\ FNF Screen size: https://github.com/FunkinCrew/Funkin/blob/master/Project.xml
	ScreenSize = Vector2.new(1280, 720); --\\ FNF is in 720p lol
	--\\ AspectRatio = 16:9

	NoteWidth = (166 * 0.7); --\\ all notes are scaled down by 0.7, so NoteWidth is the original pixel size of every note scaled down by 0.7.

	--\\ For keybinds
	KeybindNames = {
		[1] = "Left";
		[2] = "Down";
		[3] = "Up";
		[4] = "Right";
	};

	--\\ For notes
	NoteDataNames = {
		[1] = "Left";
		[2] = "Down";
		[3] = "Up";
		[4] = "Right";
	};
}

function Util.GetXPositionForNoteData(NoteData, ManualSide, PlayerSide)
	NoteData += 1 --\\ Shift it over one for roblox

	if PlayerSide == 2 then
		--\\ Normal PlayerSide
		--\\ Shenanigans for Notes.
		NoteData -= 1 --\\ Shift it back over because we are using modulo

		if ManualSide == 2 then --\\ Player2
			return (Util.ScreenSize.X - ((5 - ((NoteData % 4) + 1)) * (Util.NoteWidth)))
		end

		if ManualSide == 1 then --\\ Player1
			return (((NoteData % 4) + 1) * (Util.NoteWidth))
		end
	else
		--\\ Flip all if PlayerSide is flipped.
		--\\ Shenanigans for Notes.
		NoteData -= 1 --\\ Shift it back over because we are using modulo

		if ManualSide == 1 then --\\ Player2
			return (Util.ScreenSize.X - ((5 - ((NoteData % 4) + 1)) * (Util.NoteWidth)))
		end

		if ManualSide == 2 then --\\ Player1
			return (((NoteData % 4) + 1) * (Util.NoteWidth))
		end
	end

	return 0
end

function Util.Lerp(start, goal, t)
	return start + (goal - start) * t
end

function Util.GetStrumX(StrumIndex)
	StrumIndex += 1

	--\\ For strums
	if StrumIndex > 4 then --\\ Player2
		--\\ get screensize-x and subtract (5-(index-4) * NoteWidth)
		--\\ it's easier to understand this after you look at the one for the opponent
		return (Util.ScreenSize.X - ((5 - (StrumIndex - 4)) * (Util.NoteWidth)))
	end

	if StrumIndex < 5 then --\\ Player1
		--\\ just the product of the index and the NoteWidth
		return (StrumIndex * (Util.NoteWidth))
	end

	return 0
end

return Util
1 Like

That is totally my fault. Sorry for the late response, Here is Util:

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Source = ReplicatedStorage:WaitForChild("Source")

local Util = {
	--\\ FNF Screen size: https://github.com/FunkinCrew/Funkin/blob/master/Project.xml
	ScreenSize = Vector2.new(1280, 720); --\\ FNF is in 720p lol
	--\\ AspectRatio = 16:9

	NoteWidth = (166 * 0.7); --\\ all notes are scaled down by 0.7, so NoteWidth is the original pixel size of every note scaled down by 0.7.

	--\\ For keybinds
	KeybindNames = {
		[1] = "Left";
		[2] = "Down";
		[3] = "Up";
		[4] = "Right";
	};

	--\\ For notes
	NoteDataNames = {
		[1] = "Left";
		[2] = "Down";
		[3] = "Up";
		[4] = "Right";
	};
}

function Util.GetXPositionForNoteData(NoteData, ManualSide, PlayerSide)
	NoteData += 1 --\\ Shift it over one for roblox

	if PlayerSide == 2 then
		--\\ Normal PlayerSide
		--\\ Shenanigans for Notes.
		NoteData -= 1 --\\ Shift it back over because we are using modulo

		if ManualSide == 2 then --\\ Player2
			return (Util.ScreenSize.X - ((5 - ((NoteData % 4) + 1)) * (Util.NoteWidth)))
		end

		if ManualSide == 1 then --\\ Player1
			return (((NoteData % 4) + 1) * (Util.NoteWidth))
		end
	else
		--\\ Flip all if PlayerSide is flipped.
		--\\ Shenanigans for Notes.
		NoteData -= 1 --\\ Shift it back over because we are using modulo

		if ManualSide == 1 then --\\ Player2
			return (Util.ScreenSize.X - ((5 - ((NoteData % 4) + 1)) * (Util.NoteWidth)))
		end

		if ManualSide == 2 then --\\ Player1
			return (((NoteData % 4) + 1) * (Util.NoteWidth))
		end
	end

	return 0
end

function Util.Lerp(start, goal, t)
	return start + (goal - start) * t
end

function Util.GetStrumX(StrumIndex)
	StrumIndex += 1

	--\\ For strums
	if StrumIndex > 4 then --\\ Player2
		--\\ get screensize-x and subtract (5-(index-4) * NoteWidth)
		--\\ it's easier to understand this after you look at the one for the opponent
		return (Util.ScreenSize.X - ((5 - (StrumIndex - 4)) * (Util.NoteWidth)))
	end

	if StrumIndex < 5 then --\\ Player1
		--\\ just the product of the index and the NoteWidth
		return (StrumIndex * (Util.NoteWidth))
	end

	return 0
end

return Util
1 Like

suprised you are managing to port a game into Roblox, exciting to see where you can go with this, even though it is just FNF

I watch your career in great interest :smile:

2 Likes

Also the like end part is kind of confusing I liked the part 1 tutorial style more in my opinion!

1 Like

For very long songs, the JSON should be compressed, and removed of all it’s spaces and new lines. If it’s small enough, it could be put in a StringValue. But to reduce logic, it should be kept in a module script.

I can’t comment on anything else in the tutorial, I have something called ‘laziness’.

4 Likes

Thanks! Ill remember that from now on!

Sorry about the confusion! This was probably the hardest part to manage, as I had to delete parts of it after I overshot the 50000 character limit.

3 Likes

you could atleast show where all the scripts go

I don’t understand what you are asking. Can you clarify?

1 Like

Not all codes have been shown where they go but it doesn’t matter i just used edited RoFunk

Great tutorial, but how would you check a section that is currently active?
I cant find a way to use mustHitSection value within charts

Here is my version of this that I have worked on:

Features Include:

  • OnSectionHit (this function fires every section and checks the current mustHitSection)
  • Displayable Rating and lower Rating systems can actually be hit
  • Notes actually get destroyed now if you don’t hit them (noticed that instances would just pile up when you miss a note)
  • Psych Engine Events
  • Downscroll Support
  • Opponent Note Light Up
  • and a bunch of other misc things.

fnf game edit.rbxm (244.2 KB)
You can check out my group (The Game Recreators) to see what I have done using this edited version.

5 Likes