Skip to content

Understanding hot-reloading

As you program your game, you want to see changes live. While Vectarine supports hot-reloading, it’s important to structure your code appropriately to best take advantage of it. Let’s see how.

Vectarine thinks in terms of resources. A resource is basically a file, it can be code, an image, sound, etc. Each resource is hot-reloaded individually.

Control + RControl + R reloads the entire project which is the same as closing and reopening the project. This is different from hot-reloading.

When you edit a file, Vectarine hot-reloads it, meaning it rereads its content and updates the corresponding resource.

Each resource can depend on other resources, for example a Luau script can draw an image. Because the script holds a pointer to the image resource, when the image is hot-reloaded, the script will use the new version of the image without needing to be reloaded itself.

A script resource is a Luau table that is returned by the script file. Inside a module, you can use loader.init() to get the content of the module itself, and require(path) to get the content from other scripts.

You need to keep this in mind, when dealing with scripts requiring other scripts.

Let’s consider the following example:

main.luau
const debug = require("@vectarine/debug")
const other = require("./other.luau")
local value = other.getPopulation()
function Update()
debug.fprint("Value is ", value)
end
other.luau
const loader = require("@vectarine/loader")
const module = loader.init() or {}
function module.getPopulation()
return 3
end
return module

In this example:

  • When the game is first loaded, other is not ready, so Vectarine pauses execution of main.luau and starts loading other.luau.
  • Once other.luau is loaded, main.luau is resumed and other.getPopulation() is called, returning 3 which is printed in the console.
  • If we edit other.luau to replace the 3 by a 6, main won’t be reloaded, so 3 will still be printed.
  • If we edit main.luau again, other.getPopulation will be called once again 6 will be printed in the console.

Now let’s say we update other.luau to the following and reload the whole project (with Control + RControl + R).

other.luau
const loader = require("@vectarine/loader")
const module = loader.init() or {}
local function count(self)
return self.humans + self.aliens
end
function module.getPopulation()
local data = {
humans = 3,
aliens = 5,
count = count
}
return data
end
return module

After hot-reloading main.luau, it will display {human = 3, aliens = 5, count = function}.

Now, let’s say we edit the count function in getPopulation in other.luau. Nothing will change. This is because the count function is located in the data table. When other.luau is hot-reloaded, the value variable in main.luau stays the same and still uses the reference to the old count function.

This design where we initialize an object with functions in its properties in other.luau and keep it in main.luau is not hot-reloading friendly.

Now, let’s say we change main.luau to the following:

main.luau
const other = require("./other")
const debug = require("@vectarine/debug")
function Update()
local population = other.getPopulation()
debug.fprint("Population is ", population:count())
end

How, when changing count in other.luau, the behavior is instantly reflect in main.luau.

However, we are creating a new population object every frame which is not ideal.

The best solution is to rethink the structure of the code:

other.luau
const loader = require("@vectarine/loader")
-- The init function returns the content of the module from the past reload. This allows to keep the same table and merge the new content with the old one.
-- It's return type is marked as nil for auto-completion reasons.
const module = loader.init() or {}
-- We put the functions associated with the population type inside the module.
module.count = function(self: Population)
return self.humans + self.aliens
end
-- This function is the "constructor" for our Population type
function module.makePopulation(): Population
const data = {
humans = 3,
aliens = 5,
}
-- The metatable is set to the module itself, so that population:count() will always use the latest version of the count function, even after hot-reloading.
return setmetatable(data, { __index = module })
end
-- Our type Population has 2 fields: humans and aliens,
-- as well as all the methods defined in the module (like count).
export type Population = typeof(setmetatable(
{} :: {
humans: number,
aliens: number,
},
{ __index = module }
))
return module
main.luau
const other = require("./other.luau")
const debug = require("@vectarine/debug")
local population = other.makePopulation()
function Update()
debug.fprint("Population is ", population:count())
end

With this design, when you edit the count function in other.luau, main.luau calls the latest version without you needing to reload it or create a new population object.

What is happening here is that when you hot-reload other.luau, module is not recreated. The same table is kept and its old content is merged with the new one. This content is what gets returned by module.init() This means that main.luau has a reference to the newest version of the count function through the metatable of population and hot-reloading works as expected!

Using loader.init() has other benefits, for example you can now store the state of your game in the module and it will be preserved across hot-reloads.

other.luau
const loader = require("@vectarine/loader")
const module = loader.init() or {}
module.reloadCount = (module.reloadCount or 0) + 1
-- Do not reset the enemies table if it already exists!
module.enemies = module.enemies or {}
return module

Here, module.reloadCount will be incremented every time other.luau is hot-reloaded, and the enemies table will be initialized once and preserved across hot-reloads. This is because loader.init() returns the same table across hot-reloads, so we can use it to store state that we want to persist.

We can thus learn the following principles for organizing code in a hot-reloading friendly way:

  • Except for main.luau, avoid putting code in the global scope. Instead, put it inside functions. You don’t know when code in the global scope will be executed which can lead to issues with loading order.
  • Use function in modules and avoid storing functions in objects
  • Define one main type per module with associated functions for working with that type in the module.

Unlike scripts, some resources like images and sound are not immediately ready when loaded. You can use Graphics.drawSplashScreenIfNeeded to display a splash screen while waiting for resources to be ready.

main.luau
const loader = require("@vectarine/loader")
const graphics = require("@vectarine/graphics")
-- load your scripts
local image = loader.loadImage("textures/image.png")
local sound = loader.loadAudio("audio/sound.mp3")
function Once()
_G.isOnceCalled = true
-- Your init code here. You can be sure that the image and sound are now ready to be used.
end
function Update(deltaTime: number)
if
graphics.drawSplashScreenIfNeeded({
image,
sound,
})
then
return
end
if not _G.isOnceCalled then
Once()
end
-- Your game loop here
end

Because Update is the entry point of your game, all code that gets called from it will access resources that are already loaded. Inside drawSplashScreenIfNeeded, you can put images, sound, fonts and even scripts resources (coming from the loadScript function)!