Skip to content

TypeScript

TypeScript mods add types for game structs, hook contexts, and runtime modules. The workflow is:

TypeScript source (.ts)
       ↓  ts-luau
  Luau code (.luau)
       ↓  Petrichor
    runs in-game

Project structure#

my-mod/
├── mod.json
├── package.json
├── tsconfig.json
├── src/
│   └── main.ts      # TypeScript source
└── out/
    └── main.luau    # compiled Luau

mod.json#

Point entry at the compiled Luau output:

{
  "id":            "my-ts-mod",
  "name":          "My TypeScript Mod",
  "type":          "luau",
  "entry":         "out/main.luau",
  "version":       "1.0.0",
  "hostVersion":   "0.8.0",
  "engineVersion": "1.0.0"
}

package.json#

{
  "name": "my-ts-mod",
  "version": "1.0.0",
  "devDependencies": {
    "@courage/types": "file:path-to-crxts-types"
  }
}

Point file: at your clone of crxts-types. The path is relative to your mod directory (for example from mods/my-ts-mod/, go three levels up to the repo root, then into crxts-types).

Install dependencies:

npm install

tsconfig.json#

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "CommonJS",
    "moduleDetection": "force",
    "moduleResolution": "Node",
    "noLib": true,
    "strict": true,
    "allowSyntheticDefaultImports": true,
    "skipLibCheck": true,
    "rootDir": "src",
    "outDir": "out",
    "typeRoots": ["node_modules/@courage"]
  },
  "rbxts": {
    "type": "standalone"
  }
}

Key options:

Option Reason
"noLib": true Suppress the built-in TS lib, Luau has its own stdlib
"typeRoots": ["node_modules/@courage"] Pick up @courage/types declarations
"rbxts": { "type": "standalone" } Required by ts-luau's roblox-ts heritage

Write your mod#

-- main.luau
local Log = require("Petrichor.Log")

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

function MyMod:init()
    self:before("daCow_c::Execute", function(ctx)
        local cow = ctx.self
        Log.info("my-ts-mod", "Cow mode before Execute: " .. tostring(cow.mMode))
    end)

    self:before("daCow_c::action_move", function(ctx)
        local cow = ctx.self
        if cow.mProcess ~= nil then
            ctx.cancelled = true
        end
    end)
end

return MyMod
// src/main.ts
import * as Log from "Petrichor.Log"

declare function mod(name: string): any

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

MyMod.init = function () {
    this.before("daCow_c::Execute", (ctx: HookCtx) => {
        const cow = ctx.self
        Log.info("my-ts-mod", `Cow mode before Execute: ${cow.mMode}`)
    })

    this.before("daCow_c::action_move", (ctx: HookCtx) => {
        const cow = ctx.self
        if (cow.mProcess !== undefined) {
            ctx.cancelled = true
        }
    })
}

export = MyMod

Petrichor runs the compiled Luau once and uses the export = value as the returned mod class (same as return MyMod in Luau). See Script entry point.

Info

Hook symbols are exposed as properties on the Augment.Hook module, with :: replaced by _. So daCow_c::Execute becomes Hook.daCow_c_Execute. All overloads and contexts are inferred from the game manifest.


Compile#

# From your mod directory:
node /path/to/ts-luau/out/CLI/cli.js --project ./

Or via npx tsx if you haven't built ts-luau yet:

npx tsx /path/to/ts-luau/src/CLI/cli.ts --project ./

This writes compiled .luau files to out/ mirroring the structure of src/.


Type overview#

Declaration file Contents
courage.d.ts Entry point, hand-maintained types (cXyz, csXyz, NativePtr, Mtx)
lua.d.ts Luau standard library
generated/courage_bindings.d.ts All game structs, enums, globals, and Augment.Hook module
generated/hooks.d.ts HookCtx and HookBuilder interfaces
generated/runtime_modules.d.ts Augment.Mixin and Petrichor.Log module declarations