TypeScript

A project's scripts can be written in TypeScript instead of Lua. You choose when you make the project, and it stays that way: a project has scripts in one language only.

TypeScript is never run by the game. The editor turns each .ts file into Lua with TypeScriptToLua, and the game runs that Lua like any other. An exported game has no TypeScript and no Node.js in it, and runs the same as a Lua one.

What you need

Node.js 16 or newer, on the computer where you write the game. The editor finds it on your PATH, or where Homebrew, nvm or Volta put it. Without Node.js a TypeScript project still opens, and the console says what to install.

The people who play your game need nothing.

Making a project

In the project hub, under New project, pick TypeScript next to Scripts in. The templates listed are the ones written in TypeScript. An agent passes "language": "typescript" to project.create.

When the project opens, the editor writes these into its folder:

FileWhat it is
tsconfig.jsonYour TypeScript settings. Made once, then yours to change. It extends the next one.
.cairn/tsconfig.base.jsonThe settings the Lua depends on, written again on every open
.cairn/api.d.tsEverything a script can call, as TypeScript declarations
.cairn/lua/The Lua each script was compiled to, which is what the game runs

Commit tsconfig.json. Leave .cairn/ out of version control: the editor makes it again.

Open the project's folder in VS Code or Zed and you get completion, the description of each function on hover, and red underlines for type errors, with nothing to install.

A script

A TypeScript script ends with export = DefineScript({ ... }):

export = DefineScript({
	// Values the editor can set per entity.
	Fields: { Speed: 90 },

	OnUpdate(deltaTime: number) {
		this.Entity.Turn(0, this.Speed * deltaTime, 0);
	},
});

The table inside is what a Lua script returns: Fields, then the functions the engine calls. DefineScript hands it back as it is. It is there so TypeScript knows what this is: this.Entity is the entity, and this.Speed is a number because Fields says so.

When a script is compiled

When you save a .ts file, the editor compiles every script in the project and the running game picks up the change, as it does for Lua. A type error anywhere shows in the console as Assets/Scripts/Player.ts:12:5: what is wrong, and the game keeps running the Lua that last compiled cleanly. Double-click the line to open the script.

An error while the game runs names the line of your .ts file, not of the Lua it became.

A project cloned from version control has no compiled Lua yet. Open it in the editor, or compile it from a terminal:

Cairn-Editor MyGame/MyGame.cairnproject --headless --compile

A project with a type error does not export.

Keeping values

A value the script keeps from one function to the next is declared in its table, with a starting value. The starting value gives its type:

export = DefineScript({
	Hits: 0,
	// Found in OnStart. Until then it is an empty stand-in of the right type.
	Target: {} as Entity,

	OnStart() {
		this.Target = Scene.Find("Player");
	},

	OnTriggerEnter(other: Entity) {
		this.Hits = this.Hits + 1;
	},
});

Each entity's copy of the script gets its own once it sets one. A list or a table declared there is shared until then, so make a fresh one in OnStart before adding to it, as the Lua scripts do.

Callbacks and this

Give the engine arrow functions: Timer.After(2, () => this.Explode()). An arrow function keeps this, and TypeScript checks that it takes what the engine passes:

export = DefineScript({
	Score: 0,

	OnStart() {
		Events.On("CoinCollected", (position: Vec3, points: number) => {
			this.Score = this.Score + points;
		}, this.Entity);
		Timer.Every(1, () => print(`Score: ${this.Score}`));
	},
});

Sharing code

A file of functions exports them, and other scripts import them from beside themselves:

// Assets/Scripts/Distances.ts
export function Closest(entities: Entity[], point: Vec3): Entity | undefined {
	let best: Entity | undefined;
	for (const entity of entities) {
		if (!best || entity.WorldPosition.Distance(point) < best.WorldPosition.Distance(point)) {
			best = entity;
		}
	}
	return best;
}
import { Closest } from "./Distances";

export = DefineScript({
	OnUpdate() {
		const enemy = Closest(Scene.FindByTag("Enemy"), this.Entity.WorldPosition);
	},
});

import becomes the engine's require, so it reaches the project's own scripts only. npm packages cannot be imported.

What is different from the TypeScript you know

  • Lists count from 0, as in any TypeScript. A list the engine gives back, such as Scene.FindByTag("Coin"), is an ordinary array: coins[0] is the first and coins.length how many.
  • Several results come back as one value you take apart: const [x, y] = Input.GetMousePosition();.
  • Vectors add and scale with methods, because TypeScript has no operators for them: a.add(b), a.sub(b), a.mul(2), a.div(2) and a.unm() for -a. They become Lua's +, -, *, / and unary minus.
  • A number from the inspector or a scene can be a whole number stored as a fraction, which text shows as 3.0. Use Math.floor(n) in text that wants 3, and n.toFixed(1) for one decimal place.
  • undefined is Lua's nil. A field that names nothing, or a component an entity does not have, is undefined.
  • There is no browser and no Node.js in a game. setTimeout, fetch, JSON, Date, regular expressions, console and the DOM do not exist. Use Timer, SaveData, Time, and print or Log.Info.
  • Learn TypeScript itself from the TypeScript handbook. The TypeScriptToLua pages list every corner where it differs.

What costs extra time each frame

Most TypeScript becomes the Lua you would write by hand: calls, sums, if, loops and reading and setting values cost exactly the same. A few features need a small helper function in Lua, which is slower. They are fine in OnStart or once in a while, and worth avoiding in OnUpdate for hundreds of entities:

  • array methods such as map, filter, forEach, reduce, find and includes. Use for (const x of list)
  • spreading with ..., and Object.keys, Object.entries
  • string methods such as split, includes and toFixed
  • new on a class, and making a new table every frame

The FeatureTourTS sample is the Lua FeatureTour written this way, and its scripts are good examples. So are those of ZombieSurvival, a game written in TypeScript from the start.

Measured

The two Feature Tour samples are the same game, one in Lua and one in TypeScript written as above. Each ran its whole benchmark three times in a release build (--stats-log, hidden window, no frame cap) on an Apple M2 Pro, on 20 October 2026. The median time the scripts took each frame:

LevelLuaTypeScript
Garden0.064 ms0.049 ms
Workshop0.064 ms0.062 ms
Night Market0.047 ms0.037 ms
Foundry0.045 ms0.043 ms
Puppets0.060 ms0.061 ms
Arcade, with 400 scripted squares0.397 ms0.403 ms

No level was more than 2% slower in TypeScript, and the whole frame took about 8.3 ms in both. Differences this small are within what one run varies from the next.