[NEW] How to make a Pet System in ROBLOX 2025! | High Effort Post |

(For those of you who don’t care for the tutorial and just want the Pet System (WHICH IS OKAY!), or want to download it to work alongside, here is the file!)

PetSystem_Asyncable2025.rbxl (70.4 KB)

(Remember to turn on API Services in Game → Security settings!)

Howdy y’all!

I made my last “How to make a pet system” post back in August of 2024, which has now gotten around 8,000 views! I firstly just want to say thank you for checking out my post, I am grateful for your time and hope that I was able to help at least someone with their development journey! :grinning:

But now onto what you are all here for (assuming so atleast) the UPDATED pet system tutorial! This one is vastly more advanced than the previous! Buckle up and get ready to learn and create! (Or simply download the file and check it out yourself!)

PLEASE READ:

ADDITIONALLY! This is NOT at 100% optimization, nor to the highest quality of my professional work. This does NOT represent me as a developer entirely and should NOT be used against me in any way, this source is PURELY informational and used as a resource for others to learn!

ALSO! There is NO physical EGGS or a HATCHING ANIMATION, this is simply targeted to be a backend source

To start off, there are a few things we need to setup for this system to work, mainly, the general modularized hierarchy (structure of module scripts contained in various locations such as ServerScriptService, as well as StarterGui), in addition with some folders that we will be using throughout this tutorial!

Lets get ya’ started with setting your workspace up the same as mine! (I’ve attached a photo below)

Now that we have our workspace setup, allow me to explain what everything does in our structure!

:earth_africa: ReplicatedStorage

:file_folder: Shared [FOLDER]
Shared logic and definitions used by both client and server.

  • :scroll: EggInfo Module
    Stores :egg: egg configuration and pet chances:
    :bar_chart: Defines pet names, rarities, and odds
    :handshake: Used in hatching logic by both client/server

:file_folder: PetModels [FOLDER]
Contains :teddy_bear: 3D pet models used for display and following:
:dog2: Models cloned and placed into Workspace
:framed_picture: Used during hatching and pet rendering

(FYI, PET MODELS SHOULD BE MODELS AND HAVE A PRIMARYPART SET WITH ALL ADDITIONAL PARTS WELDED, AS SEEN BELOW) {body is the primary part and one weld for each cosmetic feature of a pet}

(FYI PT2, FOR ADVANCED/FANCIER PET MODELS THAT ARE MESHES, JUST ADD A PART TO BE THE PRIMARY PART, A (1,1,1) CUBE WORKS)

:file_folder: PlayerDatas [FOLDER]
Holds :bust_in_silhouette: synced player data instances for in-game access:
:arrows_counterclockwise: Mirrors what’s stored in Datastore
:tv: Lets GUI display or reference player data live

:file_folder: Remotes [FOLDER]
Holds :satellite: RemoteEvents/RemoteFunctions for client-server communication:

  • :satellite: SingleHatch [RemoteEvent]
    :dart: Triggered to hatch one egg
    :arrows_counterclockwise: Sends request to server for 1x hatch logic
  • :satellite: TripleHatch [RemoteEvent]
    :repeat: Triggers hatching of 3 eggs
    :sparkles: Used for triple hatch animations & rewards

:cloud: ServerScriptService

:scroll: Datastore Module
Manages all :floppy_disk: player save/load operations.

  • :clock3: On player join/leave
  • :package: Uses Template for new players
  • :broom: Version-based data key system

:scroll: Template Module
Holds :dna: default data structure for new players.

:scroll: Version Module
Contains :1234: current version used in datastore key:
:file_folder: Allows clean version upgrades
:construction: Prevents data corruption across updates

:scroll: Pets Module
Handles :paw_prints: backend logic for pet spawning and syncing:
:brain: Server creates, updates, and destroys player pets
:world_map: Positions pets in Workspace


:desktop_computer: StarterGui

:scroll: Client Module
Central client-side initializer. Loads GUI & listens to remotes.

  • :scroll: Hatching Module
    Handles :film_strip: animations and UI for hatching eggs:
    :jigsaw: Interfaces with remotes (Single/Triple Hatch)
  • :scroll: Pets Module
    Controls :paw_prints: pet behavior and rendering on the client:
    :footprints: Visual following
    :art: Appearance updates

:framed_picture: TestHatching (ScreenGui)
Testing GUI for hatching features.

  • :radio_button: Hatch (TextButton)
    :computer_mouse: Triggers a single egg hatch when clicked
  • :radio_button: TripleHatch (TextButton)
    :firecracker: Triggers a 3x hatch using the TripleHatch remote

:globe_with_meridians: Workspace

:file_folder: PlayerPets [FOLDER]
Holds :paw_prints: active pet models for each player in the world:
:round_pushpin: Pets are placed/updated here based on player actions
:arrows_counterclockwise: Managed by client-side Pets Module

Now that we have our workspace all set, we can begin with the scripting portion!

Remember that if you ever get lost, confused, or have questions, feel free to respond to this post as well as checking out the game file!

📜 Server Module
local Server = {}

local Start = tick()
local LoadedModules = {}

for _, Module in script:GetChildren() do
	if Module:IsA("ModuleScript") then
		if require(Module):: nil then
			table.insert(LoadedModules, Module)
		end
	end
end

print("Client Loaded In: " .. tick()-Start .. " Seconds")

return Server

❔ Server Module Explanation

This script automatically loads all ModuleScript children of the script it’s placed in. For each module, it checks if it can be successfully required and adds it to a list. It also tracks and prints how long the loading process takes for debugging purposes.

[SPACER]

📜 Datastore Module
--!native
local Datastore = {}

local Version = require(script.Parent.Version)
local Template = require(script.Template)

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DataStoreService = game:GetService("DataStoreService")
local HttpService = game:GetService("HttpService")
local DataStore = DataStoreService:GetDataStore("PetSystemExample" .. Version)
local PlayerDatas = ReplicatedStorage:WaitForChild("PlayerDatas")

Datastore.LoadedUsers = {}

do
	local function Player_StartSequence(player: Player)
		print(player.Name .. " loading")
		Datastore:RegisterPlayer(player)
	end

	local function Player_ExitSequence(player: Player)
		print(player.Name .. " exiting")
		Datastore:CleanupPlayer(player)
	end


	do

		game.Players.PlayerAdded:Connect(function(player: Player)
			Player_StartSequence(player)
		end)

		game.Players.PlayerRemoving:Connect(function(player: Player) 
			Player_ExitSequence(player)
		end)	

	end
end


local function JsonE(t) return HttpService:JSONEncode(t) end
local function JsonD(t) return HttpService:JSONDecode(t) end
local function deepcopy(orig)
	if typeof(orig) ~= "table" then return orig end
	local copy = {}
	for k, v in pairs(orig) do
		copy[deepcopy(k)] = deepcopy(v)
	end
	return copy
end


local function FixData(data)
	for key, default in pairs(Template) do
		if data[key] == nil then
			data[key] = deepcopy(default)
		elseif typeof(default) == "table" and typeof(data[key]) == "table" then
			for subKey, subValue in pairs(default) do
				if data[key][subKey] == nil then
					data[key][subKey] = subValue
				end
			end
		end
	end

	for key in pairs(data) do
		if not Template[key] then
			data[key] = nil
		end
	end

	return data
end


local function PathLink(player: Player, data: table)
	if not Datastore.LoadedUsers[player.UserId] then
		Datastore.LoadedUsers[player.UserId] = FixData(data)
	end
end


local function HasDataPath(player: Player, jsonData: string)
	local parsedData = JsonD(jsonData)
	PathLink(player, parsedData)
end


local function NoDataPath(player: Player)
	PathLink(player, deepcopy(Template))
end


local function RouteData(player: Player, raw)
	if raw ~= nil then
		HasDataPath(player, raw)
	else
		NoDataPath(player)
	end
end


function Datastore:RegisterPlayer(player: Player)
	local success, response
	local attempts = 0

	repeat
		if attempts > 0 then
			warn("Retrying, Attempt: " .. attempts)
			task.wait(7)
		end
		success, response = pcall(DataStore.GetAsync, DataStore, tostring(player.UserId))
		attempts += 1
	until success

	local dataFolder = Instance.new("Folder")
	dataFolder.Name = tostring(player.UserId)
	dataFolder.Parent = PlayerDatas

	RouteData(player, response)
end


function Datastore:CleanupPlayer(player: Player)
	local userId = player.UserId
	local data = Datastore.LoadedUsers[userId]
	if not data then return end

	local attempts, success, response = 0, false, nil

	repeat
		if attempts > 0 then
			warn("Retrying Cleanup, Attempt: " .. attempts)
			task.wait()
		end

		local folder = PlayerDatas:FindFirstChild(tostring(userId))
		if folder then folder:Destroy() end

		success, response = pcall(DataStore.SetAsync, DataStore, tostring(userId), JsonE(data))
		attempts += 1
	until success

	if success then
		print("Successfully saved data for " .. player.Name)
	else
		warn("Failed to save data for " .. player.Name)
	end
end


local function createOrUpdateValue(value, name, parent)
	local class
	if typeof(value) == "number" then
		class = "NumberValue"
	elseif typeof(value) == "string" then
		class = "StringValue"
	elseif typeof(value) == "boolean" then
		class = "BoolValue"
	end
	if not class then return end

	local existing = parent:FindFirstChild(name)
	if not existing or not existing:IsA(class) then
		if existing then existing:Destroy() end
		existing = Instance.new(class)
		existing.Name = name
		existing.Parent = parent
	end

	if existing.Value ~= value then
		existing.Value = value
	end
end

local function ensureFolder(name, parent)
	local folder = parent:FindFirstChild(name)
	if not folder or not folder:IsA("Folder") then
		if folder then folder:Destroy() end
		folder = Instance.new("Folder")
		folder.Name = name
		folder.Parent = parent
	end
	return folder
end

local function syncDataToInstances(data, parent)
	for key, value in pairs(data) do
		if typeof(value) == "table" then
			local subFolder = ensureFolder(key, parent)
			syncDataToInstances(value, subFolder)
		else
			createOrUpdateValue(value, key, parent)
		end
	end
end

local function filterAndSpawn(data, userId, parent)
	local playerFolder = PlayerDatas:FindFirstChild(tostring(userId))
	if not playerFolder then return end
	syncDataToInstances(data, parent or playerFolder)
end

-- Syncer
task.spawn(function()
	while task.wait() do
		for userId, userData in pairs(Datastore.LoadedUsers) do
			if userData then
				filterAndSpawn(userData, userId)
			end
		end
	end
end)


return Datastore
❔ Datastore Module Explanation

This module handles loading, saving, and syncing player data using Roblox’s DataStoreService. It creates default data from a template if none exists, stores it in ReplicatedStorage.PlayerDatas, and keeps it synced with memory in real time.

Functions:

FixData(data)

Ensures the data follows the structure of Template:

  • Fills in missing keys using defaults.
  • Cleans up extra/unexpected keys.
  • Handles nested tables recursively.

deepcopy(orig)

Recursively clones a table so that changes to one copy don’t affect the original. Used to duplicate the template safely.

JsonE(t) / JsonD(t)

Shortcuts for HttpService:JSONEncode() and JSONDecode(), used for saving and loading data as strings in Roblox’s DataStore.


PathLink(player, data)

Links cleaned and fixed player data into Datastore.LoadedUsers, ensuring each player has a valid in-memory data object.


HasDataPath(player, jsonData)

  • Called when a player’s data exists in the datastore.
  • Decodes the data and links it using PathLink.

NoDataPath(player)

  • Called when a player has no saved data.
  • Links a fresh copy of the Template.

RouteData(player, raw)

Determines whether to use HasDataPath or NoDataPath based on whether raw (the datastore result) is nil.


Datastore:RegisterPlayer(player)

  • Called when a player joins.
  • Tries to load their data from the datastore with retries on failure.
  • Creates a folder in ReplicatedStorage.PlayerDatas for the player.
  • Passes data to RouteData() to handle it.

Datastore:CleanupPlayer(player)

  • Called when a player leaves.
  • Destroys their folder in PlayerDatas.
  • Saves their in-memory data (LoadedUsers) back to the datastore.
  • Retries on failure.

createOrUpdateValue(value, name, parent)

  • Creates or updates a NumberValue or StringValue instance in a folder.
  • Ensures the value reflects the current in-memory data.

ensureFolder(name, parent)

  • Ensures a Folder exists under the given parent.
  • Destroys it and remakes it if it’s the wrong type.

syncDataToInstances(data, parent)

  • Recursively mirrors the nested table structure from in-memory data (LoadedUsers) to Roblox instances inside PlayerDatas.

filterAndSpawn(data, userId, parent)

  • Finds the player’s folder in PlayerDatas.
  • Syncs the player’s data into it using syncDataToInstances.

[SPACER]

📜 Template Module
local Template_Data = {
	Cash = 1000,
	Diamonds = 100,
	
	Pets = {},
}

return Template_Data
❔ Template Module Explanation

Simply put, the Template Module stores the default values that a player will be granted in either of the following scenarios: The player does not have any data OR there was an issue loading the data and the default template data was granted instead.

[SPACER]

📜 Pets Module
--!native

local Pets = {}

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

local EggInfo = require(ReplicatedStorage.Shared:WaitForChild("EggInfo"))
local Datastore = require(script.Parent.Datastore)

local Remotes = ReplicatedStorage:WaitForChild("Remotes")
local SingleHatch = Remotes:WaitForChild("SingleHatch")
local TripleHatch = Remotes:WaitForChild("TripleHatch")


local function rollPet(petTable)
	local totalWeight = 0
	for _, petData in pairs(petTable) do
		totalWeight += petData.Chance
	end

	local roll = math.random(1, totalWeight)
	local cumulative = 0
	for petName, petData in pairs(petTable) do
		cumulative += petData.Chance
		if roll <= cumulative then
			return petName, petData
		end
	end
end


local function hatchPets(player, eggName, count)
	local userData = Datastore.LoadedUsers[player.UserId]
	if not userData then return end

	local egg = EggInfo[eggName]
	if not egg then return end

	local totalPrice = egg.Price * count
	if userData.Coins < totalPrice then return end

	userData.Coins -= totalPrice

	userData.Pets = userData.Pets or {}

	for i = 1, count do
		local petName, petData = rollPet(egg.Pets)
		if petName then
			local petId = HttpService:GenerateGUID(false)
			userData.Pets[petId] = {
				Name = petName,
				CoinBoost = petData.CoinBoost,
				Equipped = true,
			}
		end
	end
end

SingleHatch.OnServerEvent:Connect(function(player, eggName)
	hatchPets(player, eggName, 1)
end)

TripleHatch.OnServerEvent:Connect(function(player, eggName)
	hatchPets(player, eggName, 3)
end)

return Pets

❔ Pets Module Explanation

The Pets module is used to communicate with the client for hatching pets and choosing which pet has been acquired based on the pets odds! This module also creates the pets information to be stored in the players data.

[SPACER]

| OPTIONAL |

📜 Version Module
return "b.0.0.01"
❔ Version Module Explanation

The version module is only one line, and the only use for this line is to easily switch version, intended to be implemented throughout your games structure, in this case, its used as the second half of the Key for the Datastore, seen in the Datastore module.

📜 Client Module
local Client = {}

local Start = tick()
local LoadedModules = {}

for _, Module in script:GetChildren() do
	if Module:IsA("ModuleScript") then
		if require(Module):: nil then
			table.insert(LoadedModules, Module)
		end
	end
end

print("Client Loaded In: " .. tick()-Start .. " Seconds")

return Client

❔ Client Module Explanation

This script automatically loads all ModuleScript children of the script it’s placed in. For each module, it checks if it can be successfully required and adds it to a list. It also tracks and prints how long the loading process takes for debugging purposes.

[SPACER]

📜 Pets Module
local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")

local PetModels = ReplicatedStorage:WaitForChild("PetModels")
local PetFolder = Workspace:WaitForChild("PlayerPets")
local PlayerDatas = ReplicatedStorage:WaitForChild("PlayerDatas")

local Spacing = 5
local PetSize = 3
local MaxClimbHeight = 6

local RayParams = RaycastParams.new()
local RayDirection = Vector3.new(0, -500, 0)

local function RearrangeTables(Pets, Rows, MaxRowCapacity)
	table.clear(Rows)
	local AmountOfRows = math.ceil(#Pets / MaxRowCapacity)
	for i = 1, AmountOfRows do
		table.insert(Rows, {})
	end
	for i, v in Pets do
		local Row = Rows[math.ceil(i / MaxRowCapacity)]
		table.insert(Row, v)
	end
end

local function GetRowWidth(Row, Pet)
	if Pet ~= nil then
		local SpacingBetweenPets = Spacing - Pet.PrimaryPart.Size.X
		local RowWidth = 0
		if #Row == 1 then
			return 0
		end
		for i, v in Row do
			if i ~= #Row then
				RowWidth += Pet.PrimaryPart.Size.X + SpacingBetweenPets
			else
				RowWidth += Pet.PrimaryPart.Size.X
			end
		end
		return RowWidth
	end
end


task.wait(1)

RunService.Heartbeat:Connect(function(deltaTime)
	for _, playerDataFolder in PlayerDatas:GetChildren() do
		local userId = tonumber(playerDataFolder.Name)
		if not userId then continue end

		local player = Players:GetPlayerByUserId(userId)
		if not player then continue end

		local character = player.Character
		if not character then continue end

		local hrp = character:FindFirstChild("HumanoidRootPart")
		if not hrp then continue end

		local humanoid = character:FindFirstChildOfClass("Humanoid")
		if not humanoid then continue end

		local playerPetFolder = PetFolder:FindFirstChild(tostring(userId)) or Instance.new("Folder")
		playerPetFolder.Name = tostring(userId)
		playerPetFolder.Parent = PetFolder

		local equippedPetGuids = {}
		local petsFolder = playerDataFolder:FindFirstChild("Pets")
		if petsFolder then
			for _, petFolder in petsFolder:GetChildren() do
				local equipped = petFolder:FindFirstChild("Equipped")
				local nameValue = petFolder:FindFirstChild("Name")
				if equipped and equipped:IsA("BoolValue") and equipped.Value == true then
					if nameValue and PetModels:FindFirstChild(nameValue.Value) then
						equippedPetGuids[petFolder.Name] = nameValue.Value
					end
				end
			end
		end

		for _, petModel in playerPetFolder:GetChildren() do
			if not equippedPetGuids[petModel.Name] then
				petModel:Destroy()
			end
		end

	
		for guid, petName in pairs(equippedPetGuids) do
			if not playerPetFolder:FindFirstChild(guid) then
				local template = PetModels:FindFirstChild(petName)
				if template and template:IsA("Model") and template.PrimaryPart then
					local clone = template:Clone()
					clone.Name = guid
					clone:PivotTo(hrp.CFrame)
					clone.Parent = playerPetFolder
				end
			end
		end

		local pets = {}
		local rows = {}
		for _, v in playerPetFolder:GetChildren() do
			table.insert(pets, v)
		end

		RayParams.FilterDescendantsInstances = {PetFolder, character}
		local maxRowCapacity = math.ceil(math.sqrt(#pets))
		RearrangeTables(pets, rows, maxRowCapacity)

		for i, pet in pets do
			local rowIndex = math.ceil(i / maxRowCapacity)
			local row = rows[rowIndex]
			local rowWidth = GetRowWidth(row, pet)

			local xOffset = #row == 1 and 0 or rowWidth / 2 - pet.PrimaryPart.Size.X / 2
			local x = (table.find(row, pet) - 1) * Spacing
			local z = rowIndex * Spacing
			local y = 0

			local rayResult = Workspace:Blockcast(pet.PrimaryPart.CFrame + Vector3.new(0, MaxClimbHeight, 0), pet.PrimaryPart.Size, RayDirection, RayParams)
			if rayResult then
				y = rayResult.Position.Y + pet.PrimaryPart.Size.Y / 2
			end

			local targetCFrame = CFrame.new(hrp.Position.X, 0, hrp.Position.Z)
				* hrp.CFrame.Rotation
				* CFrame.new(x - xOffset, y, z)

			pet.PrimaryPart.CFrame = pet.PrimaryPart.CFrame:Lerp(targetCFrame, 0.1)
		end
	end
end)


return true
❔ Pets Module Explanation

This modules entire job is to make the pets visually appear properly, following the proper users. If you have attempted to create a pet system previously and noticed “laggy” pets when tweening from the server, or only to the direct user, then you may notice the solution here, each client tweens EVERY players pets, still to the proper user.

[SPACER]

📜 Hatching Module
--!native
local Hatching = {}

local ReplicatedStorage =game:GetService("ReplicatedStorage")

local Player = game.Players.LocalPlayer
local PlayerGui = Player:WaitForChild("PlayerGui")

local TestHatching = PlayerGui:WaitForChild("TestHatching")
local HatchButton = TestHatching:WaitForChild("Hatch")
local TripleHatchButton = TestHatching:WaitForChild("TripleHatch")

local Remotes = ReplicatedStorage:WaitForChild("Remotes")
local SingleHatchRemote = Remotes:WaitForChild("SingleHatch")
local TripleHatchRemote = Remotes:WaitForChild("TripleHatch")

HatchButton.MouseButton1Click:Connect(function()
	print("Attempt to single hatch!")
	SingleHatchRemote:FireServer("Basic Egg")
end)

TripleHatchButton.MouseButton1Click:Connect(function()
	print("Attempt to triple hatch!")
	TripleHatchRemote:FireServer("Basic Egg")
end)

return Hatching
❔ Hatching Module Explanation

This module is a placeholder module to trigger the hatching remotes, please implement the remotes into your system properly!

I personally spent a LOT of time making this resource for all of you current/future developers! It would mean alot to me if you could leave your thoughts here!

Did you find this tutorial to be helpful?

  • Yes!
  • Sorta, but confusing at times.
  • Not really.
0 voters

Feedback is not required, but all feedback is appreciated and will get a response from me! Please also leave any additional questions you may have!

Thank you for your time <3

Asyncable

27 Likes

I just fully implemented this system and it works wonderfully! My grandkids will love this! Thank you Asyncable Studios!

7 Likes

This post is highly underrated. It looks like a great system.

1 Like

I appreciate your feedback greatly, thank you!

You weren’t lying when you said hight effort, this is very high effort I hope it helps people!

1 Like

Thank you I appreciate you!

– character limit

You should probably use these:


(---) or (< hr >)

text

(>) text

2 Likes

noted! I appreciate your feedback, I may go back and use this!

2 Likes