Skip to content

Scripts

A scene on its own just sits there. Scripts make things happen: a ship that flies when you press the arrow keys, a coin that disappears when the hero touches it, a door that opens when every enemy is gone.

A script is Odin code attached to a node. The scripts of a project are the .odin files in its scripts/ folder. When you press Play (or Build Scripts), the editor compiles them into your game together with the engine.

  1. Select the node the script is for (a Sprite, a CharacterMover…).
  2. In the inspector’s Script section, press New….
  3. The New Script dialog asks for a Name. Use a capital letter first and only letters, digits and _: PlayerController, Coin, EnemyAI.
  4. Leave Kind on Node script (attached to a node). (The other kinds, global scripts and data types, are on their own page.)
  5. Press Create.

The editor writes scripts/player_controller.odin (the name in snake case), attaches the script to the selected node, opens the file in a code tab and builds the scripts so the inspector can show its fields.

Other ways in:

Where What it does
Scripts > New Script… The same dialog (attaches the script to the selected node, if any)
Right-click a node in the scene tree > Add Script… New Script for that node. On a node that has a script, the item reads Open Script (Name)
The inspector’s Script dropdown Attach a script you already wrote, or None to take it off
The inspector’s Open in Editor Opens the node’s script in the code editor
The browser’s Scripts folder Every script file. Its preview lists the scripts the file registers and the nodes using each (click one to go there)

One script can go on many nodes: every coin can run the same Coin script, each with its own field values. A node can also run several scripts.

Here is what New Script writes for a script named Mover:

package scripts
import e "engine:."
import sdl "vendor:sdl3"
/*
Mover: attach it to a node in the editor's inspector (Script).
Fields tagged `export:""` show in the inspector and are saved in the scene. Options inside the
quotes: default=<value> min=<n> max=<n> kind=scene. Untagged fields are the script's own state.
self.node is the node it's attached to; self.scene and self.game are there too.
*/
Mover :: struct {
using script: e.Script,
speed: f32 `export:"default=100 min=0"`,
}
@(init)
register_Mover :: proc "contextless" () {
e.Script_Register(Mover, {
// Once, when the scene starts: every node exists (e.Scene_FindNode(self.scene, "World/Ship"))
ready = proc(self: ^Mover) {
},
// Every frame, after the node's own update
update = proc(self: ^Mover, events: []sdl.Event, dt: f64) {
},
})
}

Part by part:

Part What it is
package scripts Every file in scripts/ belongs to one Odin package, so your scripts can use each other’s types and procedures
import e "engine:." The Crossfire Engine. Everything it has is e.Something
import sdl "vendor:sdl3" SDL, the library under the engine. update receives SDL events and colors are sdl.Color
Mover :: struct { ... } The script’s data. Each node running the script gets its own copy
using script: e.Script Must be the first field. It gives the script self.node (the node it’s on), self.scene (that node’s scene) and self.game (the running game). using lets you write self.node rather than self.script.node
speed: f32 ... An exported field: the backquoted tag makes it show in the inspector (see below)
@(init) Runs the procedure when the game program starts, before anything else. That’s how the engine learns about your script without a list somewhere
proc "contextless" () Odin requires @(init) procedures to be “contextless”. Script_Register sets up what it needs, so just keep this line as it is
e.Script_Register(Mover, {...}) Tells the engine “there’s a script called Mover, and here is its code”. Scene files attach it by that name
ready = proc(self: ^Mover) {...} The hooks: procedures the engine calls at certain moments. self is this node’s copy of the struct

Every hook is optional: leave out the ones you don’t need.

Hook When it runs Typical use
ready = proc(self: ^T) Once, on the first frame the node is in the scene. Every node of the scene exists by then Find other nodes, set the starting state
update = proc(self: ^T, events: []sdl.Event, dt: f64) Every frame, after the node’s own update. dt is the time since the last frame, in seconds Movement, input, timers
draw = proc(self: ^T, renderer: ^sdl.Renderer) After the node draws, in the node’s own space Extra drawing (a health bar over an enemy)
destroy = proc(self: ^T) When the node is removed Tidy up (stop a sound, tell a manager). The struct itself is freed for you

Bodies (DynamicBody, CharacterMover, Area…) can also have collisionEnter, collisionExit, areaEnter and areaExit hooks; events are another way to hear about collisions.

Multiply by dt for anything that moves over time: speed * f32(dt) moves speed pixels a second however fast the computer is.

A complete script that spins its node and counts the turns:

Spinner :: struct {
using script: e.Script,
// Degrees a second (shown in the inspector)
speed: f32 `export:"default=90 min=0 max=720"`,
// The script's own state (not shown, not saved in the scene)
turns: int,
}
@(init)
register_Spinner :: proc "contextless" () {
e.Script_Register(Spinner, {
ready = proc(self: ^Spinner) {
log.infof("%s starts spinning", self.node.name)
},
update = proc(self: ^Spinner, events: []sdl.Event, dt: f64) {
self.node.rotation += self.speed * f32(dt)
if self.node.rotation >= 360 {
self.node.rotation -= 360
self.turns += 1
}
},
destroy = proc(self: ^Spinner) {
log.infof("%s spun %d times", self.node.name, self.turns)
},
})
}

(log is import "core:log"; its messages show in the editor’s Console tab while the game plays.)

A field with an export:"" tag is exported: it shows in the inspector, and the value you set there is saved in the scene. That’s how one Enemy script makes a slow goblin and a fast bat: same code, different values.

Inside the quotes go options, separated by spaces:

Option Meaning Example
default=<value> The value a new node starts with (else zero, white for colors, the first value for enums) default=100, default=#ff8080, default=64, 0
min=<n>, max=<n> The smallest and largest number the inspector allows min=0 max=500
kind=scene A string that names a scene: the inspector offers the project’s scenes
kind=<DataType> A data resource field that only lists resources of that type kind=EnemyStats

Fields without the tag are the script’s own state: not shown, not saved, starting at zero each time the scene starts.

The field types the inspector knows:

Mood :: enum {
Calm,
Angry,
Scared,
}
Enemy :: struct {
using script: e.Script,
speed: f32 `export:"default=120 min=0 max=500"`,
health: int `export:"default=3 min=1"`,
flying: bool `export:""`,
title: string `export:"default=Goblin"`,
nextLevel: string `export:"kind=scene"`,
patrol: [2]f32 `export:"default=64, 0"`,
tint: sdl.Color `export:"default=#ff8080"`,
mood: Mood `export:"default=Calm"`,
hurtSound: e.SoundAsset `export:""`,
stats: e.DataAsset `export:"kind=EnemyStats"`,
loot: e.ComponentAsset `export:""`,
body: ^e.Sprite `export:"default=Sprite"`,
camera: ^e.Camera2D `export:"default=../Camera"`,
// Not exported: the script's own
time: f64,
}
Type In the inspector
f32, f64, int, i32 A number field, kept between min and max
bool A checkbox
string A text box (with kind=scene, a dropdown of scenes)
[2]f32 Two numbers, x and y
sdl.Color A color picker
An enum A dropdown of its values
e.TextureAsset, e.SoundAsset, e.MusicAsset, e.FontAsset, e.ComponentAsset, e.AnimationAsset, e.DataAsset A dropdown of the project’s assets of that kind
A pointer to a node: ^e.Sprite, ^e.Camera2D, ^e.Node… A dropdown of the scene’s nodes of that kind

A node field holds a path from the script’s node to another node, like a file path: Sprite is a child named Sprite, ../Camera a sibling named Camera, /World/Door a path from the top of the scene. Just before ready, the engine sets the field to that node. If the node isn’t there, or isn’t of the field’s kind, the field stays nil and the Console shows a warning, so check for nil before using one you might not have set.

A quick way to make one: drag a node from the scene tree onto a code tab. The editor adds a field of the node’s kind to the script’s struct, named after the node, with its path as the default.

An asset field (hurtSound: e.SoundAsset) lets each node pick its own asset in the inspector. Dragging an asset from the browser onto a code tab adds one for you, defaulting to that asset, with a comment showing how to use it.

Once the scripts are built, the inspector’s Script section shows a row per exported field, under the Script dropdown. Hover a field’s name to see its type, range and default. Every change is undoable (Ctrl+Z), and only values that differ from the default are written to the scene.

Before the first build (or while a build fails), the inspector can’t know the fields yet: it says “Build the scripts (Ctrl+B) to edit Mover’s fields.” and lists the stored values, read only. Nothing is lost meanwhile.

Scripts > Build Scripts (Ctrl+B) compiles the scripts without starting the game. The editor also builds once in the background when a project with scripts opens, after New Script, and whenever you press Play with changed scripts.

  • The compiler’s messages go to the bottom panel’s Output tab (Ctrl+J shows the bottom panel). An error names the file and line: fix it and build again.
  • After a good build the Output tab lists what it found: 3 scripts: Coin, Hero, Mover (and data types and node types, if any). The inspector’s dropdowns use that list.
  • The Script section’s header shows “building…” during a build and “build failed: see Output” after a failed one.

Building needs Odin and a linker; Edit > Settings… > Toolchain checks them, and can install Odin for you.

Scripts open in code tabs beside the scene tabs, in the editor’s built-in code editor:

  • Completion from the Odin language server (OLS): type e. to list the engine’s procedures, or self. for your struct’s fields.
  • Errors and warnings marked as you type, go to definition, find references, quick fixes.
  • Ctrl+W closes the code tab; saving a script (and Play) saves the open tabs.
  • Drops: a scene tree node or a browser asset dropped on a code tab adds a field for it (above).

You can use another program instead: untick Edit > Settings… > Editor > Code editor and set its path and arguments there (Zed by default). The editor writes scripts/ols.json so that program’s Odin language server finds the engine too.

The code editor page covers its keys, search, and settings.

Nodes announce things through events: a body was hit, a sound finished, a menu button was pressed, a CharacterMover landed. A script can listen to another node’s event without any code to find that node.

The examples below use a Hero script that counts the coins it picks up:

Hero :: struct {
using script: e.Script,
// Coins picked up so far
coins: int,
}
  1. Select the node whose event you want (say, a coin’s Area).
  2. In the inspector’s Events section press Show. Each event is listed with what it means (hover its name).
  3. Press Listen… beside the event. A dialog lists the scene’s nodes that have a script: choose the one that should react (the Hero).
  4. The editor stores the listener on the node and adds a handler to the listener’s script file, then puts the cursor inside it:
// Coin's onCollisionEnter (Events in Coin's inspector): A body started touching it (an Area: a body came in)
Hero_onCoinCollisionEnter :: proc(self: ^Hero, source: ^e.Node, data: e.EventData) {
// collision := e.Collision_FromEvent(data)
self.coins += 1
}
@(init)
register_Hero_onCoinCollisionEnter :: proc "contextless" () {
e.Script_Listener(Hero, "onCoinCollisionEnter", Hero_onCoinCollisionEnter)
}
  • self is the listening script (the Hero’s), source the node whose event it is (the coin), data what the event carries. The comment line shows how to read it for this event.
  • The listener is shown under its event in the Events section; × removes it.

A script can publish its own events: a coin saying “I was collected”, a boss saying “phase two”.

  1. Select a node with the script. In the Events section, beside the script’s name, press New Event….
  2. Give it a Name (onCollected), What it means (shown to whoever listens), and its Data: Nothing, a whole number, a number, true or false, text, or a node.
  3. Create adds the event to the script’s struct, with a comment on how to fire it:
Coin :: struct {
using script: e.Script,
// The hero picked up a coin. Fire it: e.Event_Notify(&self.onCollected, self.node, 42)
onCollected: e.EventSubject `event:"The hero picked up a coin" data:"value := data.(int)"`,
}

Fire it from your code when it happens:

Coin_Collect :: proc(self: ^Coin) {
e.Event_Notify(&self.onCollected, self.node, 1)
e.Game_PlaySound(self.game, SOUND_COIN)
e.Scene_RemoveNode(self.scene, self.node.id)
}

After a build (Ctrl+B) the event appears in the Events section of every node with that script, with its own Listen… button. The handler reads the data the way the tag says:

Hero_onCoinCollected :: proc(self: ^Hero, source: ^e.Node, data: e.EventData) {
value := data.(int)
self.coins += value
}

Global scripts have events too (the Globals part of the Events section, with + Event): see Globals, data and saves.

The editor keeps a file scripts/assets.odin with a constant for every asset of the project, rewritten whenever you import, remove or add one, and before every build:

package scripts
import e "engine:."
TEXTURE_SHIP :: e.TextureAsset{"assets/ship.png"}
SOUND_COIN :: e.SoundAsset{"assets/coin.wav"}
SOUND_LASER :: e.SoundAsset{"assets/laser.wav"}
MUSIC_THEME :: e.MusicAsset{"assets/theme.ogg"}
FONT_PIXEL :: e.FontAsset{"assets/pixel.ttf"}
COMPONENT_BULLET :: e.ComponentAsset{"components/Bullet.component.xml"}
COMPONENT_COIN :: e.ComponentAsset{"components/Coin.component.xml"}
ATLAS_ITEMS :: "atlases/items.atlas.xml"
DATA_GOBLIN :: e.DataAsset{"data/goblin.data.json"} // EnemyStats
ACTION_JUMP :: "jump"

A constant’s name is its file’s name in capitals, anything but letters and digits turned into one underscore: hero-run.png is TEXTURE_HERO_RUN, boss fight.ogg is SOUND_BOSS_FIGHT.

Prefix Assets
TEXTURE_ Images (and Aseprite files)
SOUND_, MUSIC_ Sounds, and sounds used as streamed music
FONT_ Fonts
COMPONENT_ Components (saved node trees)
DATA_ Data resources
ANIMATION_ Animation assets (and Aseprite files’ tags)
SHADER_, ATLAS_, DIALOGUE_, LDTK_ Shaders, atlases, dialogues, LDtk projects
ACTION_, ACHIEVEMENT_ Input action names, Steam achievement names

Use these rather than typing paths: a typo is a compile error instead of a missing sound, the code editor completes them, and removing an asset turns every place that used it into a build error you can find. Don’t edit this file: the editor rewrites it. (If you remove its first comment line, the editor treats the file as yours and stops updating it.)

All of these take what a script already has: self.node, self.scene, self.game.

Code What it does
self.node.position, .rotation, .scale, .visible, .name The node the script is on
e.Scene_FindNode(self.scene, "World/Door") Another node, by name or by a path from a top-level node (nil if none)
e.Node_FindPath(self.node, "../Camera") A node by a path from this one
e.Node_GetScript(node, Health) A node’s script of a type (nil if it has none)
e.Node_Is(node, e.Sprite) Whether a node is of a kind, before cast(^e.Sprite)node
e.Input_IsActionPressed("jump"), e.Input_IsActionJustPressed("jump") Input actions (see Input)
e.Game_PlaySound(self.game, SOUND_COIN) Plays a sound once
e.Scene_AddComponent(self.scene, COMPONENT_BULLET, position = ...) Adds a copy of a component (a bullet, an explosion)
e.Scene_RemoveNode(self.scene, node.id) Removes a node and its children (next frame)
e.Asset_Texture(self.game, TEXTURE_SHIP) An image, for a Sprite’s texture
e.Game_ChangeScene(self.game, "Level2") Goes to another scene
e.Global(GameState) A global script (see Globals, data and saves)

A gun that shoots on the “shoot” action:

Gun :: struct {
using script: e.Script,
}
@(init)
register_Gun :: proc "contextless" () {
e.Script_Register(Gun, {
update = proc(self: ^Gun, events: []sdl.Event, dt: f64) {
if e.Input_IsActionJustPressed("shoot") {
e.Game_PlaySound(self.game, SOUND_LASER)
e.Scene_AddComponent(self.scene, COMPONENT_BULLET, position = e.Node_GetWorldPosition(self.node))
}
},
})
}

(e.Node_GetWorldPosition is where the node is in the scene, whatever its parents. A component added without a parent goes at the top level, so it needs the world position.)

More ways to look around from a script:

Calls :: proc(self: ^Enemy) {
// Another node of the scene, by name or by a path from a top-level node
if door := e.Scene_FindNode(self.scene, "World/Door"); door != nil do door.visible = false
// A node from this one: a child, or ../ for a sibling
sword := e.Node_FindPath(self.node, "Sword")
_ = sword
// Another script on this node
if spinner := e.Node_GetScript(self.node, Spinner); spinner != nil do spinner.speed = 0
// A node of a kind
player := e.Scene_FindNode(self.scene, "Player")
if e.Node_Is(player, e.Sprite) {
sprite := cast(^e.Sprite)player
sprite.texture = e.Asset_Texture(self.game, TEXTURE_SHIP)
}
// Another scene
if self.health <= 0 do e.Game_ChangeScene(self.game, "GameOver")
// The scene's name
log.infof("in %s", self.scene.name)
}

Scripts don’t run in the viewport: the editor shows the scene as it starts. Press Play (F5) to see them work.

A node’s script and the field values you changed are in the scene file:

<node kind="Sprite" name="Goblin" texture="assets/goblin.png">
<script name="Enemy">
<prop name="speed" value="80"/>
<prop name="camera" value="../Camera"/>
</script>
<listener event="onCollisionEnter" target="../Hero" handler="onCoinCollisionEnter"/>
</node>

My script isn’t in the Script dropdown. Build the scripts (Ctrl+B). If the Output tab shows errors, fix them first. Check the file calls e.Script_Register from an @(init) procedure.

The build says @(init) procedures must be contextless. Write proc "contextless" (), as the template does.

My field doesn’t show in the inspector. It needs the export:"" tag (with backquotes around it), a type from the table above, and a build.

My node field is nil. The path doesn’t lead to a node of that kind. Pick the node again in the inspector’s dropdown, and check the Console for the warning.

Things move faster on a faster computer. Multiply by dt.

Can I see a script run without playing? No: the viewport doesn’t run scripts. Play, and use the Live tab to look inside the running game.

Can I change a script while the game plays? Yes: save it and the game restarts where it was (hot reload).