Scripting pet sim with rojo - part 2

make sure to go check out part one if you havent: Scripting pet sim with rojo - part 1

wait… so what are we actually scripting again?

we’re making the pet system for pet simulator in order to get sued by Big Games as fast as possible!

we’re basically just gonna recreate the pet movment!

this is surprisingly complex, and since we’re normal and civilized people, we’re gonna be planning the script architecture before we actually script!

the motion

but first, lets analyze the motion


(thank you russo for the great gameplay footage)

  1. the pets are in a tile-like position
  2. the pets rotate in one axis and go up and down (excuse my terrible art)

bobbing up and down

since its tedious to script motions like this and manually replay the game over again… lets create a hoarcekat story for it!

“wait… isnt hoarcekat only for ui??” well… no… it just runs code and happens to have a “target” parameter where you can parent ui to. it’s actually a really good testing tool and i use it instead of testez

you can get hoarcekat in the roblox marketplace or just install it yourself on the github

once you install it, refresh studio, reconnect rojo (because you little gremlins can forget and wonder why your scripts dont change [same lol… same]), and now we’re ready to go!

we’ll make a folder called stories on the client, and make a file called pet.story.luau to test the pet movement

you can bring back your mouse and manually create the files because (afaik) there arent any shortcuts for creating files in vscode

it should be set up like this
image

and your initial pet.story.luau code should be like this

-- dont actually include these comments btw
-- they are just for additional commentary 

-- returns the function that is supposed to be run
return function()
  print("initiated!")
  -- returns a function that cleans up the instances spawned by this function
  return function()
    print("cleanup!")  
  end
end

since i know that 90% of you are lazy people who will just copy and paste the code, press C-S-k to delete the lines where the comments are

if you also press C-s to save, you can see that stylua autoformats it to tabs! had to put spaces in because i dont know how to put tabs in the devforum post editor… so youre stuck with this for now…

lets actually hack a solution up! i dont like spoonfeeding, so going forward ill be hiding the finished code in spoilers so that you can implement it yourself and see how i did it

first task, make a 4x4x4 part at 0, 2, 0 to simulate a pet! add more decorations if you want!

answer
return function()
	local pet = Instance.new("Part")
	pet.Size = Vector3.one * 3
	pet.Position = Vector3.yAxis * 2
	pet.Parent = workspace

	return function()
		pet:Destroy()
	end
end

after making the script, the files should sync, and after you press hoarcekat, something like this should show up:
image

just press the funny “pet” button, and the intended result should show up

image

isnt she adorable

alright, lets make her bounce!

we’ll just be using basic tweening for this (im so pissed rn because i spent like 5 hours trying to do some complex sine strategy when i realized i could just use a reversible tween this is why im not the owner of one of the biggest groups on roblox)

also this would be a really great time to tell you that the game:GetService snippet is available! just partially type “getservice” and it should appear and look like this:

answer
local tween_service = game:GetService("TweenService")

return function()
	local base_y_position = 2

	local pet = Instance.new("Part")
	pet.Size = Vector3.one * 3
	pet.Position = Vector3.yAxis * base_y_position
	pet.Parent = workspace

	local tween = tween_service:Create(
		pet,
		TweenInfo.new(0.2, Enum.EasingStyle.Quad, Enum.EasingDirection.Out, -1, true),
		{
			Position = Vector3.new(pet.Position.X, base_y_position + 2, pet.Position.Z),
		}
	)

	tween:Play()

	return function()
		pet:Destroy()
	end
end

it should (approximately) look like this!

realistically, our pets wont be bouncing forever, so lets just make the tween into a function so that we can run it when we want

we’ll just make a button in the corner of the screen to trigger the tween

by the way you need to make sure that your ui elements show up in your studio window to make them more visible. just press the UI button in the hoarcekat window and you should be good
image

your example should look approximately like this!

answer
local BOUNCE_LENGTH = 0.2
local BOUNCE_HEIGHT = 2

local tween_service = game:GetService("TweenService")

return function(target: Instance)
	local base_y_position = 2

	local pet = Instance.new("Part")
	pet.Size = Vector3.one * 3
	pet.Position = Vector3.yAxis * base_y_position
	pet.Parent = workspace

	local function bounce()
		local tween = tween_service:Create(
			pet,
			TweenInfo.new(BOUNCE_LENGTH, Enum.EasingStyle.Quad, Enum.EasingDirection.Out, 0, true),
			{
				Position = Vector3.new(pet.Position.X, base_y_position + BOUNCE_HEIGHT, pet.Position.Z),
			}
		)

		tween:Play()
	end

	local screengui = Instance.new("ScreenGui")

	local button = Instance.new("TextButton")
	button.Text = "bounce"
	button.Size = UDim2.fromOffset(200, 50)
	button.Activated:Connect(bounce)
	button.Parent = screengui

	screengui.Parent = target

	return function()
		screengui:Destroy()
		pet:Destroy()
	end
end

good! now we can just add the rotation! make sure the rotation switches like the real game (because remember we WANT the lawsuit)

your answer should (approximately) look like this!

answer
local BOUNCE_LENGTH = 0.2
local BOUNCE_HEIGHT = 2
local BOUNCE_ROTATION = 15 -- deg

local tween_service = game:GetService("TweenService")

return function(target: Instance)
	local base_y_position = 2
	local base_x_rotation = 0
	local rotate_backwards = false

	local pet = Instance.new("Part")
	pet.Size = Vector3.one * 3
	pet.Position = Vector3.yAxis * base_y_position
	pet.Parent = workspace

	local function bounce()
		local x_rotation = if rotate_backwards
			then base_x_rotation - BOUNCE_ROTATION
			else base_x_rotation + BOUNCE_ROTATION

		local tween = tween_service:Create(
			pet,
			TweenInfo.new(BOUNCE_LENGTH, Enum.EasingStyle.Quad, Enum.EasingDirection.Out, 0, true),
			{
				Position = Vector3.new(pet.Position.X, base_y_position + BOUNCE_HEIGHT, pet.Position.Z),
				Orientation = Vector3.new(x_rotation, pet.Orientation.Y, pet.Orientation.Z),
			}
		)

		rotate_backwards = not rotate_backwards

		tween:Play()
		tween.Completed:Wait()
	end

	local screengui = Instance.new("ScreenGui")

	local button = Instance.new("TextButton")
	button.Text = "bounce"
	button.Size = UDim2.fromOffset(200, 50)
	button.Activated:Connect(bounce)
	button.Parent = screengui

	screengui.Parent = target

	return function()
		screengui:Destroy()
		pet:Destroy()
	end
end

absolutely beautiful.

now its actually time to position the pets!

positioning/tiling

russo’s footage doesnt show it well enough (sorry russo), so we’ll have to consult other images

this is what a tiny amount of pets looks like (thank you woozlo)

and this is what a huge amount of pets looks like (thank you woozlo [but the white line filter hurts my eyes])

it looks like the pets are positioned in a circle where theres less in the center and more in the edges

im too lazy to count the amount of pets in each ring, so im just gonna do my own thing where the first ring is 4 pets max and each ring just adds two more pets

lets do it in another story! create a file called positioning.story.luau!!!

the file structure should look like this

image

this is gonna be a bit more complex, so we’re gonna do some more planning before diving into the code!

  1. theres gonna be several pets, so we should have a function to create them and have a table keep track of them
  2. we should use the polar coordinate system for positioning the pets in a circle
  3. each of the pets are equidistant
  4. we should have pointers for each individual pet so that they know which position they have to tween to
  5. since the pets adapt to the height of the platform they are standing on, we should probably use some raycasts to get information on the ground

lets get to work!

we dont need anything fancy right now, we just need

  1. a dummy player
  2. a function to create a pet that accepts a base cframe, the distance from the dummy, and their angle relative to the dummy

youll need some knowledge on CFrame positioning and converting polar coordinates to cartesian coordinates, so if im speaking gibberish to you right now, try googling and if you start to feel tears you can look at the answer

all you need right now is just two pets spaced like… 10 degrees apart lmao

image

answer
local function create_pet(base_cframe: CFrame, distance: number, angle: number)
	local instance = Instance.new("Part")
	instance.Size = Vector3.one * 3
	instance.CFrame = base_cframe + Vector3.new(distance * math.cos(angle), 0, distance * math.sin(angle))

	instance.Parent = workspace

	return instance
end

return function()
	local dummy_player = Instance.new("Part")
	dummy_player.Anchored = true
	dummy_player.Size = Vector3.new(2, 6, 2)
	dummy_player.Position = Vector3.yAxis * 3
	dummy_player.Parent = workspace

	local pet = create_pet(dummy_player.CFrame, 4, -10)
	local pet2 = create_pet(dummy_player.CFrame, 4, 10)

	return function()
		dummy_player:Destroy()
		pet:Destroy()
		pet2:Destroy()
	end
end

we have a good foundation here! but remember! we have to have a list of pets that dynamically change position… which is very hard!

so the next baby step is creating a loop and filling out the rows

i want 20 pets! so loop 20 times and your solution should look like this!

hint: you should probably have variables for the maximum amount of pets allowed in a row and the current amount of pets in the current row

hint 2: you should probably account for whether the amount of pets in a row is odd or even (if you want to tweak some variables :D)

hint 3: if you want to account for an odd number of pets, you can find the middle of an odd number by adding 1 to it and dividing the result by two

it should look a little something like this!

image

it looks a bit janky like this tho… so i decided to shrink the pets a bit

image

heres what an odd number of pets should look like

image

i found this to be considerably challenging myself, so ill tell you again that you can try this by yourself for 30 minutes and if youre stuck look at the answer to see how i did it

i added some commentary to the answer because its some cursed witch stuff i dont ever wanna touch again, and to be completely honest even i dont know why these solutions work lmao

answer
-- ok so this code is actually super duper confusing so we might need some commentary

local PET_AMOUNT = 20 -- amount of pets we're spawning
local PET_PADDING = 3 -- the distance between the pets so that they arent crammed
local PET_SIZE = 2
local ROW_DISTANCE = 5 -- distance between rows
local ROTATION_DISTANCE = 20 -- how many degrees apart the pets should be
local INITIAL_MAX_ROW_AMOUNT = 4 -- the amount of pets that should be in the first row
local MAX_ROW_AMOUNT_ADDER = 2 -- the amount of pets that should be added after each row
local RAYCAST_DISTANCE = 10 -- for the ground_adaptation function

local pets = {}

-- separated the raycast positioning stuff to a later function because this puppy does TWO ENTIRE CHECKS!!!
local function ground_adaptation(pet: Part)
	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.FilterDescendantsInstances = pets

	local raycast_result = workspace:Raycast(
		pet.CFrame.Position + Vector3.yAxis * (RAYCAST_DISTANCE / 2),
		Vector3.yAxis * -RAYCAST_DISTANCE,
		params
	)
	if not raycast_result then
		return nil
	end

	local instance = raycast_result.Instance

	if not instance:IsA("BasePart") then
		return nil
	end

	local computed_y = instance.Position.Y + instance.Size.Y / 2 + pet.Size.Y / 2
	return Vector3.new(pet.Position.X, computed_y, pet.Position.Z)
end

local function create_pet(base_cframe: CFrame, distance: number, angle: number)
	local instance = Instance.new("Part")
	instance.Size = Vector3.one * PET_SIZE

	-- ok so to break this down
	-- 1. we add a vector3 to position RELATIVE to the CFrame's rotation
	-- 2. we set the x to the converted x position
	-- 3. we set the z to the converted y position
	-- 4. in that conversion we use math.rad on the angle because the parameter is in degrees
	instance.CFrame = base_cframe
		+ Vector3.new(distance * math.cos(math.rad(angle)), 0, distance * math.sin(math.rad(angle)))

	local result = ground_adaptation(instance)
	if result then
		instance.Position = result
	end

	instance.Parent = workspace

	return instance
end

return function()
	local dummy_player = Instance.new("Part")
	dummy_player.Anchored = true
	dummy_player.Size = Vector3.new(2, 6, 2)
	dummy_player.Position = Vector3.yAxis * 3
	dummy_player.Parent = workspace

	local max_row_amount = INITIAL_MAX_ROW_AMOUNT
	local distance = ROW_DISTANCE
	local current_row_amount = 1

	for _ = 0, PET_AMOUNT do
		local even_row = max_row_amount % 2 == 0

		local rotation = 0
		local multiplier = current_row_amount
		-- we need this offset because the pets go to the left of the character and we need to snap them back
		local offset = if even_row then -ROTATION_DISTANCE / 2 else -ROTATION_DISTANCE

		-- we have this if statement to position the second half of the pets to the right of the character
		if even_row and current_row_amount > max_row_amount / 2 then
			multiplier = -(current_row_amount - max_row_amount / 2)
			offset = -offset
		elseif current_row_amount > (max_row_amount + 1) / 2 then
			-- to find the middle of an odd number just add one to it and divide the result by 2

			multiplier = -(current_row_amount - max_row_amount / 2)
			-- to be completely honest with you
			-- i dont know why i need to do this subtraction AND THEN divide by two
			-- but i figured this out after random experimentation and it works so we arent touching it lmao
			offset = -offset - ROTATION_DISTANCE / 2
		end

		rotation = (multiplier * ROTATION_DISTANCE) + offset
		-- we add the extra padding to the pets
		-- i have no idea what i was thinking when i added the division, but it works so shut up
		rotation *= (PET_SIZE * PET_PADDING) / max_row_amount

		table.insert(pets, create_pet(dummy_player.CFrame, distance, rotation))

		current_row_amount += 1

		if current_row_amount > max_row_amount then
			max_row_amount += MAX_ROW_AMOUNT_ADDER
			current_row_amount = 1
			distance += ROW_DISTANCE
		end
	end

	return function()
		dummy_player:Destroy()

		for _, pet in ipairs(pets) do
			pet:Destroy()
		end
	end
end

so after that cursed series of events, we finally have appropriate positioning! all we need to do now is just set the base_y_position, convert the positions into markers, and make the pets tween

the first step shouldnt be too much of a hassle. you dont have to put it under a loop or runservice yet, we’ll just run it once

make sure to add some platforms to make sure it actually works!

it should look like this (i turn it on and off again each time i change the platforms, no need for real time adjustments!)

oh yeah, by the way, if you dont like the comments in the answer, you can press C-A-Down (Control + Alt + Down [arrow key]) to create multiple cursors
combine that with the delete line keybind (C-S-k)
press Esc twice to put it back to one cursor

the whole process should look like this:

cool right? and you dont even have to move your arm to touch the mouse!

alright now this marker stuff is when it starts to get a bit more complicated, but we can sneak it into the pet creation function

we’re gonna have to use some RunService stuff now… so to help you, heres a Pet type that should be returned in the pet creation function!

type Pet = {
	instance: Part,
	-- since we have a loop instantiated we want to disconnect it and destroy the instance
	cleanup: () -> (),
}

youll also need to change base_cframe to base_part since we’re reacting to its movement

no need to tween right now, thats for later

it should look like this:

my answer is rather unclever, but it works, so here it is:

answer
-- ok so this code is actually super duper confusing so we might need some commentary

local PET_AMOUNT = 20 -- amount of pets we're spawning
local PET_PADDING = 3 -- the distance between the pets so that they arent crammed
local PET_SIZE = 2
local ROW_DISTANCE = 5 -- distance between rows
local ROTATION_DISTANCE = 20 -- how many degrees apart the pets should be
local INITIAL_MAX_ROW_AMOUNT = 4 -- the amount of pets that should be in the first row
local MAX_ROW_AMOUNT_ADDER = 2 -- the amount of pets that should be added after each row
local RAYCAST_DISTANCE = 20 -- for the ground_adaptation function

local run_service = game:GetService("RunService")

type Pet = {
	instance: Part,
	-- since we have a loop instantiated we want to disconnect it and destroy the instance
	cleanup: () -> (),
}

local pets: { Pet } = {}

-- separated the raycast positioning stuff to a later function because this puppy does TWO ENTIRE CHECKS!!!
local function ground_adaptation(pet: Part)
	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.FilterDescendantsInstances = { pet }

	local raycast_result = workspace:Raycast(
		pet.CFrame.Position + (Vector3.yAxis * (RAYCAST_DISTANCE / 2)),
		Vector3.yAxis * -RAYCAST_DISTANCE,
		params
	)
	if not raycast_result then
		return nil
	end

	local instance = raycast_result.Instance

	if not instance:IsA("BasePart") then
		return nil
	end

	local computed_y = instance.Position.Y + instance.Size.Y / 2 + pet.Size.Y / 2
	return Vector3.new(pet.Position.X, computed_y, pet.Position.Z)
end

local function create_pet(base_part: Part, distance: number, angle: number): Pet
	local instance = Instance.new("Part")
	instance.Size = Vector3.one * PET_SIZE

	local render_connection = run_service.RenderStepped:Connect(function()
		instance.CFrame = base_part.CFrame
			* CFrame.new(
				distance * math.cos(math.rad(angle)),
				base_part.Position.Y,
				distance * math.sin(math.rad(angle))
			)

		local result = ground_adaptation(instance)
		if result then
			instance.Position = result
		end
	end)

	instance.Parent = workspace

	return {
		instance = instance,
		cleanup = function()
			instance:Destroy()
			render_connection:Disconnect()
		end,
	}
end

return function()
	local dummy_player = Instance.new("Part")
	dummy_player.Anchored = true
	dummy_player.Size = Vector3.new(2, 6, 2)
	dummy_player.Position = Vector3.yAxis * 3
	dummy_player.Parent = workspace

	local max_row_amount = INITIAL_MAX_ROW_AMOUNT
	local distance = ROW_DISTANCE
	local current_row_amount = 1

	for _ = 0, PET_AMOUNT do
		local even_row = max_row_amount % 2 == 0

		local rotation = 0
		local multiplier = current_row_amount
		-- we need this offset because the pets go to the left of the character and we need to snap them back
		local offset = if even_row then -ROTATION_DISTANCE / 2 else -ROTATION_DISTANCE

		-- we have this if statement to position the second half of the pets to the right of the character
		if even_row and current_row_amount > max_row_amount / 2 then
			multiplier = -(current_row_amount - max_row_amount / 2)
			offset = -offset
		elseif current_row_amount > (max_row_amount + 1) / 2 then
			-- to find the middle of an odd number just add one to it and divide the result by 2

			multiplier = -(current_row_amount - max_row_amount / 2)
			-- to be completely honest with you
			-- i dont know why i need to do this subtraction AND THEN divide by two
			-- but i figured this out after random experimentation and it works so we arent touching it lmao
			offset = -offset - ROTATION_DISTANCE / 2
		end

		rotation = (multiplier * ROTATION_DISTANCE) + offset
		-- we add the extra padding to the pets
		-- i have no idea what i was thinking when i added the division, but it works so shut up
		rotation *= (PET_SIZE * PET_PADDING) / max_row_amount

		table.insert(pets, create_pet(dummy_player, distance, rotation))

		current_row_amount += 1

		if current_row_amount > max_row_amount then
			max_row_amount += MAX_ROW_AMOUNT_ADDER
			current_row_amount = 1
			distance += ROW_DISTANCE
		end
	end

	return function()
		dummy_player:Destroy()

		for _, pet in ipairs(pets) do
			pet.cleanup()
		end
	end
end

tween time!

except we’re not gonna be using a tween… because using tween in RunService is uh…

so we’ll use lerp!

youll also need to change the ground_adaptation function… sorry for all the changes lololol… the new parameter is base_cframe instead of pet

you should also change the params to make sure that the pets dont overlap!

it should look like this!

answer
-- ok so this code is actually super duper confusing so we might need some commentary

local PET_AMOUNT = 20 -- amount of pets we're spawning
local PET_PADDING = 3 -- the distance between the pets so that they arent crammed
local PET_SIZE = 2
local PET_SPEED = 3
local ROW_DISTANCE = 5 -- distance between rows
local ROTATION_DISTANCE = 20 -- how many degrees apart the pets should be
local INITIAL_MAX_ROW_AMOUNT = 4 -- the amount of pets that should be in the first row
local MAX_ROW_AMOUNT_ADDER = 2 -- the amount of pets that should be added after each row
local RAYCAST_DISTANCE = 20 -- for the ground_adaptation function

local run_service = game:GetService("RunService")

type Pet = {
	instance: Part,
	-- since we have a loop instantiated we want to disconnect it and destroy the instance
	cleanup: () -> (),
}

local pets: { Pet } = {}
local ground_params = RaycastParams.new()
ground_params.FilterType = Enum.RaycastFilterType.Exclude

-- separated the raycast positioning stuff to a later function because this puppy does TWO ENTIRE CHECKS!!!
local function ground_adaptation(base_cframe: CFrame)
	local raycast_result = workspace:Raycast(
		base_cframe.Position + (Vector3.yAxis * (RAYCAST_DISTANCE / 2)),
		Vector3.yAxis * -RAYCAST_DISTANCE,
		ground_params
	)
	if not raycast_result then
		return nil
	end

	local instance = raycast_result.Instance

	if not instance:IsA("BasePart") then
		return nil
	end

	return instance.Position.Y + instance.Size.Y / 2
end

local function create_pet(base_part: Part, distance: number, angle: number): Pet
	local instance = Instance.new("Part")
	instance.Size = Vector3.one * PET_SIZE
	instance.CFrame = base_part.CFrame

	-- make a bounce function
	-- keep track of a bounce input variable
	-- add delta to the bounce input variable
	-- once the bounce input variable is over 1 and the distance is far enough, run the bounce function again

	local render_connection = run_service.RenderStepped:Connect(function(delta)
		local base_cframe = base_part.CFrame
			* CFrame.new(
				distance * math.cos(math.rad(angle)),
				-base_part.Position.Y,
				distance * math.sin(math.rad(angle))
			)

		local result = ground_adaptation(base_cframe)
		if result then
			base_cframe *= CFrame.new(0, result + instance.Size.Y / 2, 0)
		end

		instance.CFrame = instance.CFrame:Lerp(base_cframe, delta * PET_SPEED)
	end)

	instance.Parent = workspace

	return {
		instance = instance,
		cleanup = function()
			instance:Destroy()
			render_connection:Disconnect()
		end,
	}
end

return function()
	local dummy_player = Instance.new("Part")
	dummy_player.Anchored = true
	dummy_player.Size = Vector3.new(2, 6, 2)
	dummy_player.Position = Vector3.yAxis * 3
	dummy_player.Parent = workspace

	local max_row_amount = INITIAL_MAX_ROW_AMOUNT
	local distance = ROW_DISTANCE
	local current_row_amount = 1

	-- since you cant do table.insert on raycastparams :(
	local filter = ground_params.FilterDescendantsInstances

	for _ = 0, PET_AMOUNT do
		local even_row = max_row_amount % 2 == 0

		local rotation = 0
		local multiplier = current_row_amount
		-- we need this offset because the pets go to the left of the character and we need to snap them back
		local offset = if even_row then -ROTATION_DISTANCE / 2 else -ROTATION_DISTANCE

		-- we have this if statement to position the second half of the pets to the right of the character
		if even_row and current_row_amount > max_row_amount / 2 then
			multiplier = -(current_row_amount - max_row_amount / 2)
			offset = -offset
		elseif current_row_amount > (max_row_amount + 1) / 2 then
			-- to find the middle of an odd number just add one to it and divide the result by 2

			multiplier = -(current_row_amount - max_row_amount / 2)
			-- to be completely honest with you
			-- i dont know why i need to do this subtraction AND THEN divide by two
			-- but i figured this out after random experimentation and it works so we arent touching it lmao
			offset = -offset - ROTATION_DISTANCE / 2
		end

		rotation = (multiplier * ROTATION_DISTANCE) + offset
		-- we add the extra padding to the pets
		-- i have no idea what i was thinking when i added the division, but it works so shut up
		rotation *= (PET_SIZE * PET_PADDING) / max_row_amount

		local pet = create_pet(dummy_player, distance, rotation)
		table.insert(pets, pet)
		table.insert(filter, pet.instance)
		ground_params.FilterDescendantsInstances = filter

		current_row_amount += 1

		if current_row_amount > max_row_amount then
			max_row_amount += MAX_ROW_AMOUNT_ADDER
			current_row_amount = 1
			distance += ROW_DISTANCE
		end
	end

	return function()
		dummy_player:Destroy()

		for _, pet in ipairs(pets) do
			pet.cleanup()
		end
	end
end

last but not least… the bounce!

thisll be a bit hard to implement since you cant quite use a tween every frame, but we’re in luck!

we can use the TweenService:GetValue function to sort of simulate a normal tween!

good luck! dont be ashamed to google or look at the answer!

it should look a little something like this (i added the hinges for CFrame.lookAt debugging):

answer
-- ok so this code is actually super duper confusing so we might need some commentary

local PET_AMOUNT = 20 -- amount of pets we're spawning
local PET_PADDING = 3 -- the distance between the pets so that they arent crammed
local PET_SIZE = 2
local PET_SPEED = 3

local BOUNCE_SPEED = 4
local BOUNCE_HEIGHT = 2
local BOUNCE_ROTATION = 15 -- deg

local ROW_DISTANCE = 5 -- distance between rows
local ROTATION_DISTANCE = 20 -- how many degrees apart the pets should be
local INITIAL_MAX_ROW_AMOUNT = 4 -- the amount of pets that should be in the first row
local MAX_ROW_AMOUNT_ADDER = 2 -- the amount of pets that should be added after each row

local RAYCAST_DISTANCE = 20 -- for the ground_adaptation function

local run_service = game:GetService("RunService")
local tween_service = game:GetService("TweenService")

type Pet = {
	instance: Part,
	-- since we have a loop instantiated we want to disconnect it and destroy the instance
	cleanup: () -> (),
}

local pets: { Pet } = {}
local ground_params = RaycastParams.new()
ground_params.FilterType = Enum.RaycastFilterType.Exclude

-- separated the raycast positioning stuff to a later function because this puppy does TWO ENTIRE CHECKS!!!
local function ground_adaptation(base_cframe: CFrame)
	local raycast_result = workspace:Raycast(
		base_cframe.Position + (Vector3.yAxis * (RAYCAST_DISTANCE / 2)),
		Vector3.yAxis * -RAYCAST_DISTANCE,
		ground_params
	)
	if not raycast_result then
		return nil
	end

	local instance = raycast_result.Instance

	if not instance:IsA("BasePart") then
		return nil
	end

	return instance.Position.Y + instance.Size.Y / 2
end

local function create_pet(base_part: Part, distance: number, angle: number): Pet
	local bounce_input = 0
	local bounce_speed = BOUNCE_SPEED
	local bounce_rotation = BOUNCE_ROTATION

	-- a cframe unaffected by the bounce stuff
	local base_cframe = base_part.CFrame

	local instance = Instance.new("Part")
	instance.Size = Vector3.one * PET_SIZE
	instance.CFrame = base_part.CFrame
	instance.FrontSurface = Enum.SurfaceType.Hinge

	local render_connection = run_service.RenderStepped:Connect(function(delta)
		local base_part_cframe = base_part.CFrame
			* CFrame.new(
				distance * math.cos(math.rad(angle)),
				-base_part.Position.Y,
				distance * math.sin(math.rad(angle))
			)

		local result = ground_adaptation(base_part_cframe)
		if result then
			base_part_cframe *= CFrame.new(0, result + instance.Size.Y / 2, 0)
		end

		base_cframe = base_cframe:Lerp(base_part_cframe, delta * PET_SPEED)

		bounce_input += delta * bounce_speed

		if bounce_input > 1 or bounce_input < 0 then
			bounce_speed *= -1

			if bounce_input > 1 then
				bounce_input = 1
			else
				bounce_input = 0
				bounce_rotation *= -1
			end
		end

		local bounce_height = tween_service:GetValue(bounce_input, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)

		local refactoredTarget =
			Vector3.new(base_part.CFrame.Position.X, base_cframe.Position.Y, base_part.CFrame.Position.Z)
		instance.CFrame = CFrame.lookAt(base_cframe.Position, refactoredTarget)
		instance.CFrame *= CFrame.Angles(math.rad(bounce_height * bounce_rotation), 0, 0)
		instance.CFrame += (Vector3.yAxis * (bounce_height * BOUNCE_HEIGHT))
	end)

	instance.Parent = workspace

	return {
		instance = instance,
		cleanup = function()
			instance:Destroy()
			render_connection:Disconnect()
		end,
	}
end

return function()
	local dummy_player = Instance.new("Part")
	dummy_player.Anchored = true
	dummy_player.Size = Vector3.new(2, 6, 2)
	dummy_player.Position = Vector3.yAxis * 3
	dummy_player.Parent = workspace

	local max_row_amount = INITIAL_MAX_ROW_AMOUNT
	local distance = ROW_DISTANCE
	local current_row_amount = 1

	-- since you cant do table.insert on raycastparams :(
	local filter = ground_params.FilterDescendantsInstances

	for _ = 0, PET_AMOUNT do
		local even_row = max_row_amount % 2 == 0

		local rotation = 0
		local multiplier = current_row_amount
		-- we need this offset because the pets go to the left of the character and we need to snap them back
		local offset = if even_row then -ROTATION_DISTANCE / 2 else -ROTATION_DISTANCE

		-- we have this if statement to position the second half of the pets to the right of the character
		if even_row and current_row_amount > max_row_amount / 2 then
			multiplier = -(current_row_amount - max_row_amount / 2)
			offset = -offset
		elseif current_row_amount > (max_row_amount + 1) / 2 then
			-- to find the middle of an odd number just add one to it and divide the result by 2

			multiplier = -(current_row_amount - max_row_amount / 2)
			-- to be completely honest with you
			-- i dont know why i need to do this subtraction AND THEN divide by two
			-- but i figured this out after random experimentation and it works so we arent touching it lmao
			offset = -offset - ROTATION_DISTANCE / 2
		end

		rotation = (multiplier * ROTATION_DISTANCE) + offset
		-- we add the extra padding to the pets
		-- i have no idea what i was thinking when i added the division, but it works so shut up
		rotation *= (PET_SIZE * PET_PADDING) / max_row_amount

		local pet = create_pet(dummy_player, distance, rotation)
		table.insert(pets, pet)
		table.insert(filter, pet.instance)
		ground_params.FilterDescendantsInstances = filter

		current_row_amount += 1

		if current_row_amount > max_row_amount then
			max_row_amount += MAX_ROW_AMOUNT_ADDER
			current_row_amount = 1
			distance += ROW_DISTANCE
		end
	end

	return function()
		dummy_player:Destroy()

		for _, pet in ipairs(pets) do
			pet.cleanup()
		end
	end
end

but once again, we want them to stop bouncing after a certain distance

now its time to make a distance check between the location marker and the actual instance’s location to see if it should bounce

oh yeah by the way the name “base_part_cframe” is a bit of a weird name and i want to change it to “marker_cframe.” you can press C-S-h to search and replace all occurrences of a word!

anyways, your result should work approximately like this:

answer
-- ok so this code is actually super duper confusing so we might need some commentary

local PET_AMOUNT = 20 -- amount of pets we're spawning
local PET_PADDING = 3 -- the distance between the pets so that they arent crammed
local PET_SIZE = 2
local PET_SPEED = 3

local BOUNCE_SPEED = 4
local BOUNCE_HEIGHT = 2
local BOUNCE_ROTATION = 15 -- deg

local ROW_DISTANCE = 5 -- distance between rows
local ROTATION_DISTANCE = 20 -- how many degrees apart the pets should be
local INITIAL_MAX_ROW_AMOUNT = 4 -- the amount of pets that should be in the first row
local MAX_ROW_AMOUNT_ADDER = 2 -- the amount of pets that should be added after each row

local RAYCAST_DISTANCE = 20 -- for the ground_adaptation function

local run_service = game:GetService("RunService")
local tween_service = game:GetService("TweenService")

type Pet = {
	instance: Part,
	-- since we have a loop instantiated we want to disconnect it and destroy the instance
	cleanup: () -> (),
}

local pets: { Pet } = {}
local ground_params = RaycastParams.new()
ground_params.FilterType = Enum.RaycastFilterType.Exclude

-- separated the raycast positioning stuff to a later function because this puppy does TWO ENTIRE CHECKS!!!
local function ground_adaptation(base_cframe: CFrame)
	local raycast_result = workspace:Raycast(
		base_cframe.Position + (Vector3.yAxis * (RAYCAST_DISTANCE / 2)),
		Vector3.yAxis * -RAYCAST_DISTANCE,
		ground_params
	)
	if not raycast_result then
		return nil
	end

	local instance = raycast_result.Instance

	if not instance:IsA("BasePart") then
		return nil
	end

	return instance.Position.Y + instance.Size.Y / 2
end

local function create_pet(base_part: Part, distance: number, angle: number): Pet
	local bounce_input = 0
	local bounce_speed = BOUNCE_SPEED
	local bounce_rotation = BOUNCE_ROTATION

	-- a cframe unaffected by the bounce stuff
	local base_cframe = base_part.CFrame

	local instance = Instance.new("Part")
	instance.Size = Vector3.one * PET_SIZE
	instance.CFrame = base_part.CFrame
	instance.FrontSurface = Enum.SurfaceType.Hinge

	local render_connection = run_service.RenderStepped:Connect(function(delta)
		local marker_cframe = base_part.CFrame
			* CFrame.new(
				distance * math.cos(math.rad(angle)),
				-base_part.Position.Y,
				distance * math.sin(math.rad(angle))
			)

		local result = ground_adaptation(marker_cframe)
		if result then
			marker_cframe *= CFrame.new(0, result + instance.Size.Y / 2, 0)
		end

		base_cframe = base_cframe:Lerp(marker_cframe, delta * PET_SPEED)

		bounce_input += delta * bounce_speed

		local marker_distance = (marker_cframe.Position - base_cframe.Position).Magnitude
		if bounce_input >= 1 or bounce_input <= 0 then
			bounce_speed *= -1
			bounce_input = if bounce_input > 1 then 1 else 0

			if bounce_input <= 0 then
				bounce_speed = if marker_distance < 1 then 0 else BOUNCE_SPEED
				bounce_rotation *= -1
			end
		end

		local bounce_height = tween_service:GetValue(bounce_input, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)

		local refactoredTarget =
			Vector3.new(base_part.CFrame.Position.X, base_cframe.Position.Y, base_part.CFrame.Position.Z)
		instance.CFrame = CFrame.lookAt(base_cframe.Position, refactoredTarget)
		instance.CFrame *= CFrame.Angles(math.rad(bounce_height * bounce_rotation), 0, 0)
		instance.CFrame += (Vector3.yAxis * (bounce_height * BOUNCE_HEIGHT))
	end)

	instance.Parent = workspace

	return {
		instance = instance,
		cleanup = function()
			instance:Destroy()
			render_connection:Disconnect()
		end,
	}
end

return function()
	local dummy_player = Instance.new("Part")
	dummy_player.Anchored = true
	dummy_player.Size = Vector3.new(2, 6, 2)
	dummy_player.Position = Vector3.yAxis * 3
	dummy_player.Parent = workspace

	local max_row_amount = INITIAL_MAX_ROW_AMOUNT
	local distance = ROW_DISTANCE
	local current_row_amount = 1

	-- since you cant do table.insert on raycastparams :(
	local filter = ground_params.FilterDescendantsInstances

	for _ = 0, PET_AMOUNT do
		local even_row = max_row_amount % 2 == 0

		local rotation = 0
		local multiplier = current_row_amount
		-- we need this offset because the pets go to the left of the character and we need to snap them back
		local offset = if even_row then -ROTATION_DISTANCE / 2 else -ROTATION_DISTANCE

		-- we have this if statement to position the second half of the pets to the right of the character
		if even_row and current_row_amount > max_row_amount / 2 then
			multiplier = -(current_row_amount - max_row_amount / 2)
			offset = -offset
		elseif current_row_amount > (max_row_amount + 1) / 2 then
			-- to find the middle of an odd number just add one to it and divide the result by 2

			multiplier = -(current_row_amount - max_row_amount / 2)
			-- to be completely honest with you
			-- i dont know why i need to do this subtraction AND THEN divide by two
			-- but i figured this out after random experimentation and it works so we arent touching it lmao
			offset = -offset - ROTATION_DISTANCE / 2
		end

		rotation = (multiplier * ROTATION_DISTANCE) + offset
		-- we add the extra padding to the pets
		-- i have no idea what i was thinking when i added the division, but it works so shut up
		rotation *= (PET_SIZE * PET_PADDING) / max_row_amount

		local pet = create_pet(dummy_player, distance, rotation)
		table.insert(pets, pet)
		table.insert(filter, pet.instance)
		ground_params.FilterDescendantsInstances = filter

		current_row_amount += 1

		if current_row_amount > max_row_amount then
			max_row_amount += MAX_ROW_AMOUNT_ADDER
			current_row_amount = 1
			distance += ROW_DISTANCE
		end
	end

	return function()
		dummy_player:Destroy()

		for _, pet in ipairs(pets) do
			pet.cleanup()
		end
	end
end

FINALLY!!! we’ve finished the prototype and we actually get to apply it to the player!

now you know how you can make convenient prototypes with hoarcekat instead of having to press the play button a million times!

hopefully you can use this knowledge in future projects to work even faster!!!

but back to business

actually applying it to the player

now we can finally edit the client/init.client.luau file!

this script is in the StarterPlayerScripts file, which means that we’ll have to handle the player joining, spawning, dying, and leaving

don’t worry though, we’ll do this step by step

your first task is just to hook the Player.CharacterAdded, and Humanoid.Died events to a print statement

by the way, you need to type local function manually a ton of times right? i get pretty sick of it too, which is why we’re making our own snippets!

go to the top bar, press File > Preferences > Configure User Snippets

from there, type “luau” and you should pop into a new file called “lua.json” (no this isnt a typo apparently they are the same now)

snippet syntax is a whole other rabbit hole, but basically the template goes

"[title]": {
  "prefix": "[shortcut]",
  "body": [
    "[line 1]",
    "[line 2]",
    "[line 3]"
  ],
  "description": "a demonstration for the reader!"
}

lets convert this into a function snippet!

"function": {
	"prefix": "func",
	"body": [
		"local function ${1:name}(${2:param})",
		"	$0",
		"end"
	],
	"description": "A luau function"
}

ok, cool, but what the hell are those dollars and numbers doing there?

these are called “placeholders”! they basically move your cursor to where they are when you press Tab and they are immensely helpful for speed

a normal placeholder is just $[number] and theyre just places where your cursor can jump

another type of placeholder is the one with default text. theyre defined like ${[number]:[text]} and theyre just like normal placeholders except they already have text and you select it when you go to the placeholder

weirdly, the $0 is actually the LAST placeholder you go to… which is a bit confusing…

but after you save the file, it should come out like this:

try to make snippets when you can! they are extremely useful!

answer
local players = game:GetService("Players")
local player = players.LocalPlayer

local function destroy_pets()
	print("ethically escort the pets from the mortal domain!")
end

local function spawn_pets(character: Model)
   local humanoid = character:FindFirstChildWhichIsA("Humanoid")

    print("spawn the pets!")

    humanoid.Died:Connect(destroy_pets)
end

player.CharacterAdded:Connect(spawn_pets)

alright, now try to port the prototype to the actual script!

it should look like this!

answer
-- ok so this code is actually super duper confusing so we might need some commentary

local PET_AMOUNT = 20 -- amount of pets we're spawning
local PET_PADDING = 3 -- the distance between the pets so that they arent crammed
local PET_SIZE = 2
local PET_SPEED = 3

local BOUNCE_SPEED = 4
local BOUNCE_HEIGHT = 2
local BOUNCE_ROTATION = 15 -- deg

local ROW_DISTANCE = 5 -- distance between rows
local ROTATION_DISTANCE = 20 -- how many degrees apart the pets should be
local INITIAL_MAX_ROW_AMOUNT = 4 -- the amount of pets that should be in the first row
local MAX_ROW_AMOUNT_ADDER = 2 -- the amount of pets that should be added after each row

local RAYCAST_DISTANCE = 20 -- for the ground_adaptation function

local players = game:GetService("Players")
local player = players.LocalPlayer
local run_service = game:GetService("RunService")
local tween_service = game:GetService("TweenService")

type Pet = {
	instance: Part,
	-- since we have a loop instantiated we want to disconnect it and destroy the instance
	cleanup: () -> (),
}

local pets: { Pet } = {}
local ground_params = RaycastParams.new()
ground_params.FilterType = Enum.RaycastFilterType.Exclude

-- separated the raycast positioning stuff to a later function because this puppy does TWO ENTIRE CHECKS!!!
local function ground_adaptation(base_cframe: CFrame)
	local raycast_result = workspace:Raycast(
		base_cframe.Position + (Vector3.yAxis * (RAYCAST_DISTANCE / 2)),
		Vector3.yAxis * -RAYCAST_DISTANCE,
		ground_params
	)
	if not raycast_result then
		return nil
	end

	local instance = raycast_result.Instance

	if not instance:IsA("BasePart") then
		return nil
	end

	return instance.Position.Y + instance.Size.Y / 2
end

local function create_pet(base_part: Part, distance: number, angle: number): Pet
	local bounce_input = 0
	local bounce_speed = BOUNCE_SPEED
	local bounce_rotation = BOUNCE_ROTATION

	-- a cframe unaffected by the bounce stuff
	local base_cframe = base_part.CFrame

	local instance = Instance.new("Part")
	instance.Size = Vector3.one * PET_SIZE
	instance.CFrame = base_part.CFrame
	instance.FrontSurface = Enum.SurfaceType.Hinge

	local render_connection = run_service.RenderStepped:Connect(function(delta)
		local marker_cframe = base_part.CFrame
			* CFrame.new(
				distance * math.cos(math.rad(angle)),
				-base_part.Position.Y,
				distance * math.sin(math.rad(angle))
			)

		local result = ground_adaptation(marker_cframe)
		if result then
			marker_cframe *= CFrame.new(0, result + instance.Size.Y / 2, 0)
		end

		base_cframe = base_cframe:Lerp(marker_cframe, delta * PET_SPEED)

		bounce_input += delta * bounce_speed

		local marker_distance = (marker_cframe.Position - base_cframe.Position).Magnitude
		if bounce_input >= 1 or bounce_input <= 0 then
			bounce_speed *= -1
			bounce_input = if bounce_input > 1 then 1 else 0

			if bounce_input <= 0 then
				bounce_speed = if marker_distance < 1 then 0 else BOUNCE_SPEED
				bounce_rotation *= -1
			end
		end

		local bounce_height = tween_service:GetValue(bounce_input, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)

		local refactoredTarget =
			Vector3.new(base_part.CFrame.Position.X, base_cframe.Position.Y, base_part.CFrame.Position.Z)
		instance.CFrame = CFrame.lookAt(base_cframe.Position, refactoredTarget)
		instance.CFrame *= CFrame.Angles(math.rad(bounce_height * bounce_rotation), 0, 0)
		instance.CFrame += (Vector3.yAxis * (bounce_height * BOUNCE_HEIGHT))
	end)

	instance.Parent = workspace

	return {
		instance = instance,
		cleanup = function()
			instance:Destroy()
			render_connection:Disconnect()
		end,
	}
end

local function destroy_pets()
	for _, pet in ipairs(pets) do
		pet.cleanup()
	end
end

local function spawn_pets(character: Model)
	local rootpart = character:FindFirstChild("HumanoidRootPart")
	local humanoid = character:FindFirstChildWhichIsA("Humanoid")
	local max_row_amount = INITIAL_MAX_ROW_AMOUNT
	local distance = ROW_DISTANCE
	local current_row_amount = 1

	-- since you cant do table.insert on raycastparams :(
	local filter = ground_params.FilterDescendantsInstances

	for _ = 0, PET_AMOUNT do
		local even_row = max_row_amount % 2 == 0

		local rotation = 0
		local multiplier = current_row_amount
		-- we need this offset because the pets go to the left of the character and we need to snap them back
		local offset = if even_row then -ROTATION_DISTANCE / 2 else -ROTATION_DISTANCE

		-- we have this if statement to position the second half of the pets to the right of the character
		if even_row and current_row_amount > max_row_amount / 2 then
			multiplier = -(current_row_amount - max_row_amount / 2)
			offset = -offset
		elseif current_row_amount > (max_row_amount + 1) / 2 then
			-- to find the middle of an odd number just add one to it and divide the result by 2

			multiplier = -(current_row_amount - max_row_amount / 2)
			-- to be completely honest with you
			-- i dont know why i need to do this subtraction AND THEN divide by two
			-- but i figured this out after random experimentation and it works so we arent touching it lmao
			offset = -offset - ROTATION_DISTANCE / 2
		end

		rotation = (multiplier * ROTATION_DISTANCE) + offset
		-- we add the extra padding to the pets
		-- i have no idea what i was thinking when i added the division, but it works so shut up
		rotation *= (PET_SIZE * PET_PADDING) / max_row_amount

		local pet = create_pet(rootpart, distance, rotation)
		table.insert(pets, pet)
		table.insert(filter, pet.instance)
		ground_params.FilterDescendantsInstances = filter

		current_row_amount += 1

		if current_row_amount > max_row_amount then
			max_row_amount += MAX_ROW_AMOUNT_ADDER
			current_row_amount = 1
			distance += ROW_DISTANCE
		end
	end

	humanoid.Died:Connect(destroy_pets)
end

player.CharacterAdded:Connect(spawn_pets)

so it works! except… uhhh… the pets are facing the wrong direction… and they collide with you! try fixing that!

answer

ill just have screenshots of the changes since they are relatively small changes

image

we’re finished

finally… we’re finished… it took me like eight hours to finish this tutorial… lmao…

hopefully you know how to be a bit more efficient with your text editing and prototyping

anyways, i dont need to drag this out more than i need to, have a good day!

16 Likes

Sounds cool until you notice that you have a lawsuit… lol still good job. I would love to see if you can make say other games? Using rojo?

4 Likes

yeah you can basically do anything you can do in studio with rojo
rojo can do a lot more than just syncing scripts so ill maybe make a more advanced tutorial if i feel like it

2 Likes

I remember in his original tutorial, he originally mentioned that he made a pet sim game to test his lawyer and Rojo [idk why I read it like Roho, like the Spanish J makes a sound similar to the English H]

its actually pronounced roho so you got it right the first time

1 Like

vouch, I can confirm I got sued by big games 0.5 milliseconds after finishing this tutorial

ok.

Nice tutorial!

By the way, you can use :AddToFilter on RaycastParams, it acts like table.insert.

What why isn’t this properly documented anywhere??? I always use RaycastFilter.FilterDescendantsInstances whenever needed to add instances to the filter

The documentation is right here: RaycastParams | Documentation - Roblox Creator Hub

2 Likes