Skip to content

Scripts, globals, data and saves

Scripts attached to nodes, global scripts, data resources, saved games and hot reload’s snapshots.

Files: script.odin, globals.odin, data.odin, save.odin, hot_reload.odin.

Scripts: game code written in Odin, attached to the nodes of a scene.

A script is a struct whose first field is using script: Script, plus the procedures the engine calls for it. It’s registered once, by name, and scene files attach it to a node by that name (<script name="Player">). When the scene is built, the engine allocates the struct in the node’s memory arena (it’s freed with the node), fills in its exported fields from the scene file, and calls its procedures:

ready once, on the first update after the node entered the scene (every node of the scene
exists by then, so it can look others up)
update every frame, after the node's own update
draw after the node draws, in the node's local space (optional)
destroy when the node is freed (optional; the struct's memory is freed anyway)

A project keeps its scripts in one Odin package (scripts/); each file registers its script from an @(init) procedure, so linking the package in is enough:

package scripts
import "core:log"
import e "engine:."
import sdl "vendor:sdl3"
Player :: struct {
using script: e.Script,
speed: f32 `export:"default=250 min=0"`,
lives: int `export:"default=3"`,
// Not exported: state the script keeps for itself
velocity: [2]f32,
}
@(init)
register_Player :: proc "contextless" () {
e.Script_Register(Player, {
ready = proc(self: ^Player) {
log.infof("%s is ready", self.node.name)
},
update = proc(self: ^Player, events: []sdl.Event, dt: f64) {
self.node.position.x += self.speed * f32(dt)
},
})
}

Exported fields are the ones tagged export:""; they show in the editor’s inspector and are saved in the scene. Supported types: f32, f64, int, i32, bool, string, [2]f32, enums, sdl.Color (#rrggbb), and the asset handles of assets.odin (TextureAsset, SoundAsset, FontAsset, DataAsset, ComponentAsset, MusicAsset, AnimationAsset), and pointers to nodes (^e.Node, ^e.Sprite, ^e.CharacterMover…). Tag options, space-separated inside the quotes: default=<value> (else the type’s zero value; white for colors, the first value for enums), min=<n>, max=<n>, and kind=scene for a string naming a scene (kind=<DataType> for a DataAsset).

Node fields hold a path to another node (Node_FindPath: relative to the script’s node, as Sword or ../Camera; from the scene’s root with a leading /). Just before ready they’re set to that node, if it’s there and of the field’s type (or a type that embeds it: a ^e.PhysicsBody field takes a DynamicBody); otherwise they stay nil, with a warning. An empty path leaves the field nil. Only node scripts get them (globals and data resources have no node).

Hero :: struct {
using script: e.Script,
sword: ^e.Area `export:"default=Sword"`,
camera: ^e.Camera2D `export:"default=../Camera"`,
}

One script per node. The registry is global (scripts register before main).

Global scripts: game-wide state and services that live for the whole run, across scenes (score, lives, settings, a music manager, a HUD).

Any registered script can be global: project.xml lists the globals, in the order they start, with their exported field values (the editor’s Project Settings > Globals):

<global script="GameState">
<prop name="lives" value="3"/>
</global>

When the game starts (Game_LoadProject) each global gets its struct (fields from those values: every run starts from them, so Play always resets the game’s state), then the scenes are built, then the globals’ ready runs in order. Any script reaches a global by its type:

state := e.Global(GameState)
state.score += 10

Hooks (all optional; self.node and self.scene are nil for globals, self.game is set):

  • ready, update (every frame, before the scene’s nodes), destroy (when the game ends)
  • sceneChanged(self, scene): the game switched scenes
  • draw(self, renderer): draws over the scene in screen space (not moved by the camera), after the scene, in the globals’ order. Leave it out for globals that draw nothing; set self.hidden to skip it for a while
  • loaded(self): Load_Game just restored its fields (save.odin)

Globals are also what Save_Game saves: their state (fields that aren’t exported, unless tagged save:"-", and exported ones tagged save:"": save.odin).

Data resources: reusable game data (enemy stats, items, levels, dialogue) as project files, separate from code.

A data type is a struct registered like a script, with exported fields:

EnemyStats :: struct {
health: int `export:"default=10 min=1"`,
speed: f32 `export:"default=100"`,
sprite: e.TextureAsset `export:""`,
}
@(init)
register_EnemyStats :: proc "contextless" () {
e.Data_Register(EnemyStats)
}

Each instance is a file, data/<name>.data.json, listed in project.xml as an asset of kind “Data” (so exports ship it), made and edited in the editor:

{"formatVersion": 1, "type": "EnemyStats", "values": {"health": "30", "speed": "80"}}

Values are text in the same forms as script fields in scene files (Script_SetField); missing ones get their default. Data(EnemyStats, DATA_GOBLIN) loads a file the first time and returns the same instance after that: everyone shares it, so treat it as read-only (copy it to change it). It lives as long as the game.

Saving and loading game state as JSON: what’s in the globals (globals.odin).

e.Save_Game() // into the "save" slot
e.Load_Game() // false if there's no save yet
e.Save_Game("slot2")

A save holds every global’s state by field name:

  • Fields that aren’t exported are the game’s state: saved, unless tagged save:"-" (caches, pointers to nodes).
  • Exported fields (export:"") are settings from the editor: not saved, so changing them in the editor reaches players who have a save. Tag one save:"" when the player changes it too (a chosen ship, a volume): then the save’s value wins. Fields that can’t be written as JSON are skipped with a warning. Numbers, bools, strings, enums (by name), arrays, slices, dynamic arrays, maps, nested structs and asset handles all work. Loading sets the fields a save has and leaves the others (fields added to a global since the save keep their values), then calls the globals’ loaded hook.
{
"formatVersion": 1,
"globals": {
"GameState": {"score": 120, "lives": 2, "highScore": 900}
}
}

Where saves go

  • Desktop: the per-user data folder SDL picks (SDL_GetPrefPath): <user data>/<game name>/saves/<slot>.json. Games run from the editor (--dev) use <game name> (dev), so testing never touches the saves of an exported game.
  • Web: the browser’s localStorage, under <game name>/<slot>.

Restarting a game where it was: what an editor’s hot reload does when scripts change (the editor rebuilds the game, then restarts it in the same state).

  • Game_Snapshot writes the game’s state as JSON: the scene playing, the globals (what Save_Game writes) and every node of the scene’s properties (Node_Properties: position, rotation, scale, tint, visibility, an AnimatedSprite’s animation, a state machine’s state, every script field…), by the node’s path of names (“World/Ship”).
  • Game_RestoreSnapshot puts it back into a freshly started game: the globals at once, the nodes after the scene’s scripts’ ready (so a script’s ready doesn’t undo it). Nodes the new scene doesn’t have, and properties that no longer exist (a field renamed), are skipped.
  • The editor asks over the live link (snapshot <file>: live.odin) and starts the new game with player --restore <file>.

What isn’t carried over: nodes made at run time (bullets, spawned enemies), physics velocities, timers and tweens in flight, sounds playing, and the fields of scripts that aren’t exported and aren’t in globals. The game restarts its scene from the scene file, then those values go on top.

Script :: struct {
// The node it's attached to (nil for globals: globals.odin)
node: ^Node,
// The node's scene (a global's: nil, see globals.odin)
scene: ^Scene,
// The game it runs in
game: ^Game,
// Globals: skip `draw` while set
hidden: bool,
}

The first field of every script struct: what the script is attached to

struct. Source: script.odin:81

ScriptHooks :: struct($T: typeid) {
// Once, on the first update after the node entered the scene (every node of it exists then)
ready: proc(self: ^T),
// Every frame the node may process, after the node's own update (deltaTime in seconds)
update: proc(self: ^T, events: []sdl.Event, deltaTime: f64),
// After the node draws, in the node's local space
draw: proc(self: ^T, renderer: ^sdl.Renderer),
// When the node is freed (the struct's memory is freed with the node anyway)
destroy: proc(self: ^T),
// Physics body nodes only: another body started / stopped touching this one (or entered /
// left it, for an Area). `collision` is valid during the call
collisionEnter: proc(self: ^T, collision: ^Collision),
// Physics body nodes only: another body stopped touching this one (or left it, for an Area)
collisionExit: proc(self: ^T, collision: ^Collision),
// Physics body nodes only: this body entered / left an Area (`collision.other`)
areaEnter: proc(self: ^T, collision: ^Collision),
// Physics body nodes only: this body left an Area (`collision.other`)
areaExit: proc(self: ^T, collision: ^Collision),
// Globals only (globals.odin): the game switched to `scene`; Load_Game restored the fields
sceneChanged: proc(self: ^T, scene: ^Scene),
// Globals only (globals.odin): `Load_Game` restored the fields
loaded: proc(self: ^T),
}

The procedures a script can have; all optional

struct. Source: script.odin:93

ScriptInfo :: struct {
// The struct's type name without its package ("Player"): what scene files attach
name: string,
// The script struct's type
type: typeid,
// typeid_of(^T): what JSON loading needs (save.odin)
pointerType: typeid,
// size_of the struct, in bytes
size: int,
// align_of the struct
align: int,
// Where Script_Register was called, so the editor can open the file
file: string,
// The line of that call
line: int,
// Its exported fields, in declaration order
fields: []ScriptField,
// The events it publishes: its EventSubject fields tagged `event:"..."`, named "script.<field>"
// (`Script_FindEvent`; node_events.odin)
events: []NodeEventInfo,
// The hooks of `ScriptHooks`, taking the struct as a rawptr (nil: not given)
ready: proc(self: rawptr),
// ScriptHooks.update
update: proc(self: rawptr, events: []sdl.Event, deltaTime: f64),
// ScriptHooks.draw
draw: proc(self: rawptr, renderer: ^sdl.Renderer),
// ScriptHooks.destroy
destroy: proc(self: rawptr),
// ScriptHooks.collisionEnter
collisionEnter: proc(self: rawptr, collision: ^Collision),
// ScriptHooks.collisionExit
collisionExit: proc(self: rawptr, collision: ^Collision),
// ScriptHooks.areaEnter
areaEnter: proc(self: rawptr, collision: ^Collision),
// ScriptHooks.areaExit
areaExit: proc(self: rawptr, collision: ^Collision),
// ScriptHooks.sceneChanged
sceneChanged: proc(self: rawptr, scene: ^Scene),
// ScriptHooks.loaded
loaded: proc(self: rawptr),
}

A registered script (Script_Register): its type, its exported fields and its hooks, untyped

struct. Source: script.odin:118

ScriptFieldType :: enum {
// f32 or f64
Float,
// int or i32
Int,
Bool,
String,
// [2]f32, as "x, y"
Vector2,
// sdl.Color, as `#rrggbb` or `#rrggbbaa`
Color,
// Any enum, by value name
Enum,
// Asset handles (assets.odin): the value is the asset's path
Texture,
Sound,
Font,
Data,
Component,
Music,
// A pointer to a node (`^e.Sprite`...): the value is a path (`Node_FindPath`), resolved
// before `ready`
Node,
// An `AnimationAsset` (an animation set's path)
Animation,
}

What an exported field holds, as the scene file and the editor see it

enum. Source: script.odin:161

ScriptField :: struct {
// The field's name in the struct (and in `<prop name="...">`)
name: string,
// What kind of value it holds
type: ScriptFieldType,
// Its byte offset in the script struct
offset: uintptr,
// The field's Odin type (f32 vs f64, int vs i32, which enum)
odinType: typeid,
// Text form of the value used when the scene file has none
defaultValue: string,
// The tag's `min=`: numbers below it are raised to it (nil: no minimum)
min: Maybe(f64),
// The tag's `max=`: numbers above it are lowered to it (nil: no maximum)
max: Maybe(f64),
// "scene" for a string naming a scene; for a Data field, the data type it holds (`kind=EnemyStats`);
// for a Node field, the node type it points to, without its package ("CharacterMover")
hint: string,
// Node fields: the node type the pointer points to (`Node`, `Sprite`...)
nodeType: typeid,
// Enum fields: the names, in order
enumNames: []string,
}

An exported field of a script

struct. Source: script.odin:189

GlobalData :: struct {
// The registered script's name
script: string,
// Its exported field values, as text
props: [dynamic]ScriptPropData,
}

A global in project.xml: its script and field values

struct. Source: globals.odin:38

GlobalInstance :: struct {
// The global's registered script
info: ^ScriptInfo,
// The global's struct, in the game's arena
data: rawptr,
}

A running global (Game.globals)

struct. Source: globals.odin:46

DataInfo :: struct {
// The struct's type name without its package ("EnemyStats"): what data files name
name: string,
// The struct's type
type: typeid,
// size_of the struct, in bytes
size: int,
// align_of the struct
align: int,
// Where Data_Register was called, so the editor can open the file
file: string,
// The line of that call
line: int,
// Its exported fields, in declaration order
fields: []ScriptField,
}

A registered data type (Data_Register)

struct. Source: data.odin:39

DataResourceData :: struct {
// The data type's name (`DataInfo.name`)
type: string,
// The field values as text, sorted by field name
values: [dynamic]ScriptPropData,
}

What a data file holds (ParseDataResource): its type’s name and its field values as text

struct. Source: data.odin:57

Snapshot :: struct {
version: int,
scene: string,
globals: string,
nodes: []struct { path: string, props: map[string]string },
}

A snapshot’s contents (temp allocated)

struct. Source: hot_reload.odin:85

Script_Register :: proc "contextless" ($T: typeid, hooks: ScriptHooks(T), loc := #caller_location)

Registers a script type under its type name. Call it from an @(init) procedure in the script’s file; Odin requires those to be proc "contextless", and this procedure is too, so it can be called there directly. Registering the same name twice replaces the first. A struct whose first field isn’t using script: Script isn’t registered (an error is printed to stderr).

Inputs

  • T: The script struct; its first field must be using script: Script
  • hooks: Its procedures (all optional)
  • loc: Where it’s called from (kept so the editor can open the file)

Example

package scripts
import e "engine:."
import sdl "vendor:sdl3"
Spinner :: struct {
using script: e.Script,
speed: f32 `export:"default=90"`,
}
@(init)
register_Spinner :: proc "contextless" () {
e.Script_Register(Spinner, {
update = proc(self: ^Spinner, events: []sdl.Event, dt: f64) {
self.node.rotation += self.speed * f32(dt)
},
})
}

procedure. Source: script.odin:258

Script_Find :: proc(name: string) -> ^ScriptInfo

The script registered under a name, or nil.

Inputs

  • name: The script’s name (“Player”)

Returns

  • Its info, or nil when no script has that name

procedure. Source: script.odin:306

Script_All :: proc() -> []^ScriptInfo

Every registered script, sorted by name (temp allocated)

procedure. Source: script.odin:311

Node_AttachScript :: proc(node: ^Node, info: ^ScriptInfo, values: []ScriptPropData) -> rawptr

Attaches a script to a node: allocates its struct in the node’s arena and sets its exported fields from values (name, value text; missing ones get their default).

Inputs

  • node: The node
  • info: The script
  • values: Field values from the scene file

Returns

  • The script’s struct, or nil if the node already has one

procedure. Source: script.odin:330

Node_Scripts :: proc(node: ^Node) -> []^NodeScript

Every script on a node, the first one first (temp allocated).

Inputs

  • node: The node

Returns

  • Pointers to its scripts (valid until another is attached)

procedure. Source: script.odin:385

Node_GetScript :: proc(node: ^Node, $T: typeid) -> ^T

The script attached to a node, as its struct type, or nil if it has none or another script.

Inputs

  • node: The node (nil gives nil)
  • T: The script struct’s type

Returns

  • The node’s script struct, or nil

Example

Player :: struct {
using script: e.Script,
lives: int `export:"default=3"`,
}
if player := e.Node_GetScript(node, Player); player != nil do player.lives -= 1

procedure. Source: script.odin:412

Script_SetField :: proc(self: rawptr, field: ScriptField, text: string, allocator := context.allocator) -> bool

Sets a script field from its text form, as stored in scene files.

Inputs

  • self: The script struct
  • field: Which field
  • text: The value
  • allocator: For string fields

Returns

  • Whether the text was a valid value for the field

procedure. Source: script.odin:431

Script_DescribeJSON :: proc(allocator := context.allocator) -> string

A description of every registered script and data type, as JSON: what player --describe-scripts prints so the editor can show exported fields in its inspector. {"scripts": [...], "data": [...]}, each with name, file, line, and for each field name, type, default, min/max (when set), hint, and enum names.

Inputs

  • allocator: Allocator for the string

Returns

  • The JSON text

procedure. Source: script.odin:502

Global :: proc($T: typeid, game: ^Game = nil) -> ^T

The game’s global of type T.

Inputs

  • T: The global’s script type
  • game: The game (default: the one playing)

Returns

  • The global (the game’s, kept for its whole run), or nil if the project has none of that type

Example

package mygame
import e "engine:."
GameState :: struct {
using script: e.Script,
score: int,
}
AddPoints :: proc(points: int) {
if state := e.Global(GameState); state != nil do state.score += points
}

procedure. Source: globals.odin:77

Data_Register :: proc "contextless" ($T: typeid, loc := #caller_location)

Registers a data type, so data files can hold it and the editor can make and edit them. Call it from an @(init) proc "contextless" in the type’s file, like Script_Register. Registering a name again replaces it.

Inputs

  • T: The data struct (exported fields: export:"" tags)
  • loc: Where it’s called from (filled in by the compiler)

Example

package mygame
import e "engine:."
EnemyStats :: struct {
health: int `export:"default=10 min=1"`,
speed: f32 `export:"default=100"`,
}
@(init)
register_EnemyStats :: proc "contextless" () {
e.Data_Register(EnemyStats)
}

procedure. Source: data.odin:91

Data_Find :: proc(name: string) -> ^DataInfo

The data type registered under a name.

Inputs

  • name: The type’s name without its package (“EnemyStats”)

Returns

  • Its info, or nil if no data type has that name

procedure. Source: data.odin:109

Data_All :: proc() -> []^DataInfo

Every registered data type, sorted by name.

Returns

  • The types’ info (the slice is temp allocated)

procedure. Source: data.odin:119

Data :: proc($T: typeid, asset: DataAsset, game: ^Game = nil) -> ^T

A data resource, loaded the first time and shared after that.

Inputs

  • T: Its type (registered with Data_Register)
  • asset: The data file (a DATA_... constant from the generated assets.odin, or an exported DataAsset field)
  • game: The game (default: the one playing)

Returns

  • The instance (shared and kept for the game’s life: treat it as read-only), or nil (logged) if the file is missing, isn’t valid, or holds another type

Example

package mygame
import e "engine:."
EnemyStats :: struct {
health: int `export:"default=10 min=1"`,
}
DATA_GOBLIN :: e.DataAsset{"data/goblin.data.json"}
GoblinHealth :: proc() -> int {
stats := e.Data(EnemyStats, DATA_GOBLIN)
return stats != nil ? stats.health : 10
}

procedure. Source: data.odin:155

ParseDataResource :: proc(text: string, allocator := context.allocator) -> (data: DataResourceData, err: ProjectError)

Parses a data file’s text.

Inputs

  • text: The file’s JSON
  • allocator: For the result

Returns

  • data: Its type name and values (allocated with allocator; free with DataResourceData_Destroy)
  • err: .None; .Invalid_XML when it isn’t JSON (the error for any unreadable file); .Wrong_Root when it isn’t an object with a “type”; .Unsupported_Version when it’s from a newer DATA_FORMAT_VERSION

procedure. Source: data.odin:186

FormatDataResource :: proc(type: string, values: []ScriptPropData, allocator := context.allocator) -> string

Writes the text of a data file (what the editor saves; ParseDataResource reads it back). Values are written as JSON strings in the given order, so the output is deterministic.

Inputs

  • type: The data type’s name
  • values: The field values as text
  • allocator: For the text

Returns

  • The file’s JSON (allocated with allocator)

procedure. Source: data.odin:228

DataResourceData_Destroy :: proc(data: ^DataResourceData, allocator := context.allocator)

Frees what ParseDataResource allocated and zeroes the data.

Inputs

procedure. Source: data.odin:245

Save_Game :: proc(slot := "save", game: ^Game = nil) -> bool

Saves the game: every global’s fields, as JSON, in a slot.

Inputs

  • slot: The save’s name (letters, digits, - and _ are safest: it’s a file name)
  • game: The game (default: the one playing)

Returns

  • Whether it was written (failures are logged); false when there’s no game

Example

if e.Save_Game() do log.info("saved")

procedure. Source: save.odin:58

Load_Game :: proc(slot := "save", game: ^Game = nil) -> bool

Loads a save into the globals and calls their loaded hooks.

Inputs

  • slot: The save’s name, as given to Save_Game
  • game: The game (default: the one playing)

Returns

  • false if there’s no such save or it can’t be read (the globals are left as they were)

Example

if e.Save_Exists() && !e.Load_Game() do log.warn("the save couldn't be loaded")

procedure. Source: save.odin:84

Save_Exists :: proc(slot := "save", game: ^Game = nil) -> bool

Whether a save slot exists.

Inputs

  • slot: The save’s name
  • game: The game (default: the one playing)

Returns

  • true if it exists; false too when there’s no game

procedure. Source: save.odin:108

Save_Delete :: proc(slot := "save", game: ^Game = nil)

Deletes a save slot (nothing happens if there’s none).

Inputs

  • slot: The save’s name
  • game: The game (default: the one playing)

procedure. Source: save.odin:122

Save_ToJSON :: proc(game: ^Game, allocator := context.allocator) -> (string, bool)

The globals as save JSON (what Save_Game writes). Fields that can’t be written as JSON are skipped with a warning.

Inputs

  • game: The game
  • allocator: For the text

Returns

  • The JSON text (allocated with allocator), and true

procedure. Source: save.odin:140

Save_FromJSON :: proc(game: ^Game, text: string) -> bool

Sets the globals’ fields from save JSON (what Load_Game reads). Fields the save doesn’t have are left alone; globals it doesn’t name too. Doesn’t call the loaded hooks (Load_Game does). Strings and other memory it reads go in the game’s arena.

Inputs

  • game: The game
  • text: The save’s JSON

Returns

procedure. Source: save.odin:173

Game_Snapshot :: proc(game: ^Game) -> string

The game’s state as JSON (temp allocated): the scene, the globals, every node’s properties.

Inputs

  • game: The game

Returns

  • The JSON text

procedure. Source: hot_reload.odin:39

Game_RestoreSnapshot :: proc(game: ^Game, text: string) -> bool

Puts a snapshot back (Game_Snapshot’s JSON): the globals now, the nodes’ properties after the next update of the scene (its scripts’ ready). The game must be playing the snapshot’s scene (the player’s --restore starts it there).

Inputs

  • game: The game
  • text: The snapshot

Returns

  • false when the text isn’t a snapshot

procedure. Source: hot_reload.odin:75

Snapshot_Parse :: proc(text: string) -> (snapshot: Snapshot, ok: bool)

Reads a snapshot’s JSON (temp allocated); false when it isn’t one

procedure. Source: hot_reload.odin:93

Node_NamePath :: proc(node: ^Node) -> string

A node’s path of names from the top of its scene (“World/Ship”), what Scene_FindNode takes (temp allocated).

procedure. Source: hot_reload.odin:102

DATA_FORMAT_VERSION :: 1

The data file format written by the editor

constant. Source: data.odin:65

SAVE_FORMAT_VERSION :: 1

The save format written by Save_ToJSON; saves from a newer version aren’t loaded

constant. Source: save.odin:43

GAME_SNAPSHOT_VERSION :: 1

The format Game_Snapshot writes

constant. Source: hot_reload.odin:28