Skip to content

Script entry point#

Petrichor loads luau mods as classes. Your entry file must return a class from mod(). Petrichor calls new() once, then calls tick each frame and shutdown on exit.

Minimal example#

local Log = require("Petrichor.Log")

local HelloCow = mod("hello-cow")

function HelloCow:init()
    self:before("daCow_c::Execute", function(ctx)
        Log.info("hello-cow", "A cow is about to Execute!")
    end)
end

return HelloCow
import * as Log from "Petrichor.Log"

declare function mod(name: string): any

const HelloCow = mod("hello-cow")

HelloCow.init = function () {
    this.before("daCow_c::Execute", (ctx: HookCtx) => {
        Log.info("hello-cow", "A cow is about to Execute!")
    })
}

export = HelloCow

What the loader does#

For a luau mod, Petrichor:

  1. Reads entry in mod.json (default main.luau).
  2. Compiles and runs it once in the shared VM.
  3. Expects a class table back, not an instance.
  4. Checks for tick, shutdown, and new (mod() inherits the first two from Petrichor.Mod).
  5. Calls Class.new() (init runs here if defined).
  6. Each frame: task scheduler tick, then instance:tick(delta). Courage passes DELTA_TIME / 30.0f as delta (real frame time in 30 Hz units, not a fixed 1.0).
  7. On shutdown: instance:shutdown(), then pending task work is cleared.

Missing methods or a bad return value fails load with a log error.

mod(name)#

Global from Petrichor.Standard. Luau equivalent of mod():

newclass(name, require("Petrichor.Mod"))
// Call mod(name) directly. newclass is Luau-only.
declare function mod(name: string): any
const MyMod = mod("my-mod-id")

Use at the top of your entry file and return the class at the bottom:

local MyMod = mod("my-mod-id")

function MyMod:init() ... end

return MyMod
declare function mod(name: string): any

const MyMod = mod("my-mod-id")

MyMod.init = function () {
    // ...
}

export = MyMod

Match the string to your id in mod.json when you can. It shows up in logs.

Petrichor.Mod base class#

require("Petrichor.Mod") is what mod() extends. Use mod() in game mods.

Method Description
init(self, ...) Optional. Runs from new(). Register hooks here.
tick(self, delta) Optional. Default no-op. Per-frame update.
shutdown(self) Optional. Default no-op. Called on host shutdown.
before(self, sym, fn, priority?) Before-hook, fn(ctx). priority is ignored; use Augment.Mixin.register to control order.
after(self, sym, fn, priority?) After-hook, fn(ctx). priority is ignored; use Augment.Mixin.register to control order.
replace(self, sym, fn, priority?) Replace-hook, fn(ctx). priority is ignored; use Augment.Mixin.register to control order.
field(self, ptr, offset, kind) Augment.Mixin.read.
setField(self, ptr, offset, kind, value) Augment.Mixin.write.
save(self) Calls Petrichor.Storage.save() if storage is loaded for this mod.
fire(self, event, data?) Fires <mod_id>:<event> on Petrichor.Events.
on(self, event, mod_id, fn) Subscribes to <mod_id>:<event>.
once(self, event, mod_id, fn) One-shot subscription to <mod_id>:<event>.
off(self, event, mod_id, fn) Unsubscribes fn from <mod_id>:<event>.

Same as calling Augment.Hook directly:

Hook[sym]:before(function(ctx) fn(ctx) end)
import * as Hook from "Augment.Hook"

Hook.Foo_bar.before((ctx) => fn(ctx))

Augment.Hook does not take a priority argument. Use Augment.Mixin.register for ordering (see Hooks reference).

TypeScript notes#

  • Compile to out/main.luau and set entry there. See TypeScript.
  • The compiled chunk must export = the mod class.
  • Declare global mod in TypeScript. Petrichor injects it at runtime.

Plain newclass() without mod()#

return newclass("MyMod", require("Petrichor.Mod")) works for internal tooling. Game mods should use mod().