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)
- the pets are in a tile-like position
- 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
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:
just press the funny “pet” button, and the intended result should show up
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
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
this is gonna be a bit more complex, so we’re gonna do some more planning before diving into the code!
- theres gonna be several pets, so we should have a function to create them and have a table keep track of them
- we should use the polar coordinate system for positioning the pets in a circle
- each of the pets are equidistant
- we should have pointers for each individual pet so that they know which position they have to tween to
- 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
- a dummy player
- 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
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!
it looks a bit janky like this tho… so i decided to shrink the pets a bit
heres what an odd number of pets should look like
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!
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!