Hooks
Hooks are a powerful modding tool you can use to customise nearly everything about Kristal.
Let's learn what they are and how to use them!
What is a hook?
A hook is the name we give to code that attaches itself to an existing class, effectively "hooking" onto it.
Strictly speaking, hooks actually replace code - but we can (and often do) run the original code as well as our hook.
Hooks can be applied to almost anything in Kristal - allowing you to interact with or modify pretty much everything about the engine!
Hooks allow you to achieve the same effect as modifying and adding to the engine source, without altering anything outside of your mod.
And - unlike source modifications - hooks won't interfere with your ability to upgrade to new Kristal versions later. They're a win-win scenario!
Making a Hook
An important prerequisite to hooking is having a copy of the source code available.
If you don't already, it is highly recommended you use the Kristal source code. You can follow the steps here to get and use it.
Alternatively, you can reference the source code from GitHub. (But we really recommend downloading it!)
With that out of the way, let's make some hooks!
In Kristal, hooks are performed in files located in scripts/hooks/
with the Utils.hookScript(target)
function.
Each hook targets exactly one class at a time, so to create a new hook, we start by creating a file named after the target.
Say you wanted to hook the EnemyBattler
class. You would create EnemyBattler.lua
inside scripts/hooks/
. Then, you would insert the following:
local EnemyBattler, super = Utils.hookScript(EnemyBattler)
return EnemyBattler
These two very important lines must be at the top and bottom of the file respectively. All code goes between them.
And... we've successfully set up a hook!
... What does it do right now? Well... nothing...
Let's change that!
With hookScript
, whenever we define a function on EnemyBattler
, it will be hooked over the original.
We can use the regular syntax we would for writing out functions in our hook. Everything from here on out works as if we were in EnemyBattler
itself.
If we wanted to hook over the EnemyBattler:init
function, we would simply add this:
function EnemyBattler:init(...)
-- New init() code goes here
end
Whatever code we put in here will be run whenever EnemyBattler:init
is called.
There's one more thing to consider, too.
When you hook any function, the original is replaced. That means whatever it previously did doesn't happen anymore.
You'll still be able to access the super
(original) class though, and therefore you can run the original version of the function through it:
function EnemyBattler:init(...)
super.init(self, ...) -- Calls the original EnemyBattler:init function.
end
If you want the original code to run, you must use the super class to call it from inside the hook.
(If you don't know whether you should or shouldn't call the super
function, you probably should.)
Making a Hook (continued)
Let's try hooking another function on EnemyBattler. This time we'll be looking at EnemyBattler:hurt
.
You can hook this function in the same as before:
function EnemyBattler:hurt(...)
super.hurt(self, ...)
end
But... having our function arguments as ...
here isn't great. They'll pass into the original function fine, but we can't use them in our hook like this.
Navigating the Source Code
This is where having the source code is important. There's one easy place for us to find those arguments - let's look for EnemyBattler:hurt
in the source code!
Searching every file would be painstaking, so we'll need to make appropriate use of search functions.
Firstly, you need to know what file it's in - that's usually easy. If the object is EnemyBattler
, then the file will be called enemybattler.lua
(the lowercase of the object name). Then, we need to find the file itself.
In VSCode, opening the source code folder and searching for enemybattler
in the top searchbar will lead you to it. Similarly GitHub has the go to file
searchbox. (Other editors will likely have similar search features)
Then, we need to find the function. Use Ctrl+F
to search up EnemyBattler:hurt
and you should be taken straight to the function!
What we want to do is swap ...
for the arguments listed there.
If you've done it correctly, the hook should now look like this:
function EnemyBattler:hurt(amount, battler, on_defeat, color, show_status, attacked)
super.hurt(self, amount, battler, on_defeat, color, show_status, attacked)
end
Now we can access the arguments passed into each EnemyBattler:hurt
call and do whatever we want with them.
For the purposes of introducing hooks, we'll keep it very simple and just log some values to the console - but you can very easily change them around as much as you want!
With our hook set up like this, we can run code before and after the original function whenever it's called. As well as that, we can modify the arguments going into the super.hurt
function if we want to.
Note: Original Function Modifications
You'll notice that we specify here how our hook setup means we can run code before and after the original. If you need to change how the original function works, you need a slightly different setup.
Hooks can't partially modify the code of an existing function, but we can instead leverage how they replace the original to do what we need.
Rather than calling the original, you can copy the source code of the target function into the hook.
Watch out for any instances of the super
class being used in the original function. super
in the context of the original class is the same as super.super
in the context of it's hook, so change any of them accordingly.
From there, you can modify the code in your hook however you like, and it will run instead of the original.
We won't be needing this for our hook example though - let's jump back into that.
Making a Hook - Running Code
It's time to make our hook do something! It won't be very interesting, but we mainly want to demonstrate that this hook works.
Using Kristal.Console:log(text)
we can write anything to the Kristal console (which can be opened with the `
key.)
Let's add some logging about the Enemy HP before and after being hurt to start with:
function EnemyBattler:hurt(amount, battler, on_defeat, color, show_status, attacked)
-- Code above the original function runs before it:
Kristal.Console:log("Enemy " .. self.name .. " has " .. self.health .. " HP.")
super.hurt(self, amount, battler, on_defeat, color, show_status, attacked)
-- Code below the original function runs after it:
Kristal.Console:log("Enemy " .. self.name .. " has " .. self.health .. " HP.")
Kristal.Console:log("-------------") -- Draw a big line at the end so we can easily see where each Hurt ends
end
Here, we're able to access the EnemyBattler
instance each hurt
call runs for and it's properties through self
.
Let's show that we can interact with function arguments too.
Have a go at writing out how you would log the amount of damage (stored in the amount
parameter) being done to the enemy.
Your code for logging the damage should look something like this:
Kristal.Console:log("Hurting enemy for " .. amount .. " damage")
If we can read them, then we can also change them - let's show that working too.
Our target will be the color
argument. It controls the colour of the damage number that appears on the enemy.
Normally this is based on the party member dealing the damage, but we're going to change it to a really sickly, yucky yellow instead.
The quickest way to do this is by reassigning color
before calling super.hurt
. That simply means adding this line into the first half of our function:
color = {176/255, 176/255, 20/255} -- A very yucky shade of yellow
That's the last of the changes!
Let's take a look at our whole hook before we give it a test run:
local EnemyBattler, super = Utils.hookScript(EnemyBattler)
function EnemyBattler:hurt(amount, battler, on_defeat, color, show_status, attacked)
-- Code above the original function runs before it:
Kristal.Console:log("Enemy " .. self.name .. " has " .. self.health .. " HP.") -- Log the enemy HP (Before Hurt)
color = {176/255, 176/255, 20/255} -- Change the damage color
Kristal.Console:log("Hurting enemy for " .. amount .. " damage") -- Log the damage being dealt
super.hurt(self, amount, battler, on_defeat, color, show_status, attacked) -- Call the original hurt function
-- Code below the original function runs after it:
Kristal.Console:log("Enemy " .. self.name .. " has " .. self.health .. " HP.") 00 Log the enemy HP (after Hurt)
Kristal.Console:log("-------------")
end
return EnemyBattler
When we open the console, we should expect to see our three log messages for every time we hit the enemy, and see that the damage text is now a yucky yellow colour.
Let's check it out! Load up any battle and attack the enemies.
That's what we wanted to see! Our hook works!
New Functions
Hooks aren't just limited to existing functions - we can define new ones too.
This code, for example, will add coolNewFunction
to every EnemyBattler
.
function EnemyBattler:coolNewFunction()
Kristal.Console:log("Cool New Function is running!")
for i=1, 3 do
local e = Explosion(love.math.random(0, 40), love.math.random(0, 40))
self:addChild(e)
end
end
Of course, this function only does anything if it actually gets called by something else.
Try running coolNewFunction()
on an EnemyBattler
somewhere and see what happens!
If you need an example, you could make it run when ACTing. Choose any enemy you want and any ACT within there. Then call coolNewFunction
within it:
function MyEnemy:onAct(battler, name)
if name == "MyAct" then
-- Imagine some ACT code here...
self:coolNewFunction()
return { "* What" }
end
end
Hook Annotations
With hooks, you are able to change function parameters or add new functions to classes.
If you're using VSCode with our recommended extensions, you'll be familiar with annotations or atleast seeing documentation appear as you code.
Though an issue you'll quickly encounter between them and hooks is that the language server doesn't understand when you've hooked things...!
We don't want that to happen! We need to annotate any changes in our hooks to clear up the language server's confusion.
The main thing you can always do is add the following annotation line at the very top of your hook file, above even the hookScript call:
---@class ClassName : ClassName
Change ClassName
to the name of the class you're hooking.
You should also annotate/document any changes you've made to existing functions, but new additions should be picked up roughly by auto-annotations.
If you're curious about how to annotate your hooks, read up on the lua extension's annotation system here.
Even if you aren't using VSCode yourself, you may want to do this anyway if you're making a library, as users of your library can benefit from those annotations if they are provided.
It is also possible to hook functions in Kristal with the Utils.hook()
function.
However, this is not recommended as it does not support the Language Server and is overall less consistent with the engine than script hooking.
But if you need to know about it, it is documented on this page.