Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Scripting

Flint’s scripting system provides runtime game logic through Rhai, a lightweight embedded scripting language. Scripts can read and write entity data, respond to game events, control animation and audio, and hot-reload while the game is running.

Overview

The flint-script crate integrates Rhai into the game loop:

  • ScriptEngine — compiles and runs .rhai scripts, manages per-entity state (scope, AST, callbacks)
  • ScriptSync — discovers entities with script components, handles hot-reload by watching file timestamps
  • ScriptSystem — implements RuntimeSystem for game loop integration, running in update() (variable-rate)

Scripts run each frame during the update() phase, after physics and before rendering. This gives them access to the latest physics state while allowing their output to affect the current frame’s visuals.

Script Component

Attach a script to any entity with the script component:

[entities.my_door]
archetype = "door"

[entities.my_door.script]
source = "door_interact.rhai"
enabled = true
FieldTypeDefaultDescription
sourcestring""Path to .rhai file (relative to the scripts/ directory)
enabledbooltrueWhether the script is active

Script files live in the scripts/ directory next to your scene file.

Event Callbacks

Scripts define behavior through callback functions. The engine detects which callbacks are defined in each script’s AST and only calls those that exist:

CallbackSignatureWhen It Fires
on_initfn on_init()Once when the script is first loaded
on_updatefn on_update()Every frame. Use delta_time() for frame delta
on_collisionfn on_collision(other_id)When this entity collides with another
on_trigger_enterfn on_trigger_enter(other_id)When another entity enters a trigger volume
on_trigger_exitfn on_trigger_exit(other_id)When another entity exits a trigger volume
on_actionfn on_action(action_name)When an input action fires (e.g., "jump", "interact")
on_interactfn on_interact()When the player presses Interact near this entity
on_draw_uifn on_draw_ui()Every frame after on_update, for 2D HUD draw commands
on_collision_exitfn on_collision_exit(other_id)When a contact with another entity ends
on_scene_enter / on_scene_exitfn on_scene_enter()Around scene transitions (see Scene Transition API)
on_sequence_cuefn on_sequence_cue(sequence, cue)An animation sequence passed a cue event (see Animation)
on_animation_endfn on_animation_end(clip)A once sprite clip finished (see 2D Sprites)

The on_interact callback is sugar for the common pattern of proximity-based interaction. It automatically checks the entity’s interactable component for range (default 3.0) and enabled (default true) before firing.

API Reference

All functions are available globally in every script. Entity IDs are passed as i64 (Rhai’s native integer type).

Entity API

FunctionReturnsDescription
self_entity()i64The entity ID of the entity this script is attached to
this_entity()i64Alias for self_entity()
get_entity(name)i64Look up an entity by name. Returns -1 if not found
entity_exists(id)boolCheck whether an entity ID is valid
entity_name(id)StringGet the name of an entity
has_component(id, component)boolCheck if an entity has a specific component
get_component(id, component)MapGet an entire component as a map (or () if missing)
get_field(id, component, field)DynamicRead a component field value
set_field(id, component, field, value)Write a component field value
get_position(id)MapGet entity position as #{x, y, z}
set_position(id, x, y, z)Set entity position
get_rotation(id)MapGet entity rotation (euler degrees) as #{x, y, z}
set_rotation(id, x, y, z)Set entity rotation (euler degrees)
distance(a, b)f64Euclidean distance between two entities
set_parent(child_id, parent_id)Set an entity’s parent in the hierarchy
get_parent(id)i64Get the parent entity ID (-1 if none)
get_children(id)ArrayGet child entity IDs as an array
get_world_position(id)MapWorld-space position as #{x, y, z} (accounts for parent transforms)
set_material_color(id, r, g, b, a)Set the material base color (RGBA, 0.0–1.0)
find_entities_with(component)ArrayAll entity IDs that have the given component
entity_count_with(component)i64Count of entities with the given component
spawn_entity(name)i64Create a new entity. Returns its ID or -1 on failure
despawn_entity(id)Remove an entity from the world

Input API

FunctionReturnsDescription
is_action_pressed(action)boolWhether an action is currently held
is_action_just_pressed(action)boolWhether an action was pressed this frame
is_action_just_released(action)boolWhether an action was released this frame
action_value(action)f64Analog value for Axis1d actions (0.0 if not bound)
mouse_delta_x()f64Horizontal mouse movement this frame
mouse_delta_y()f64Vertical mouse movement this frame

Action names are defined by input configuration files and are fully customizable per game. The built-in defaults include: move_forward, move_backward, move_left, move_right, jump, interact, sprint, weapon_1, weapon_2, reload, fire. Games can define arbitrary custom actions in their input config TOML files and query them from scripts with is_action_pressed("custom_action").

Input bindings support keyboard, mouse, and gamepad devices. See Physics and Runtime: Input System for the config file format and layered loading model.

Time API

FunctionReturnsDescription
delta_time()f64Seconds since last frame
total_time()f64Total elapsed time since scene start

Audio API

Audio functions produce deferred commands that the player processes after the script update phase:

FunctionDescription
play_sound(name)Play a non-spatial sound at default volume
play_sound(name, volume)Play a non-spatial sound at the given volume (0.0–1.0)
play_sound_at(name, x, y, z, volume)Play a spatial sound at a 3D position
play_sound_at(name, x, y, z, volume, pitch)As above, with a pitch multiplier
stop_sound(name)Stop a playing sound

Sound names match the audio files loaded from the audio/ directory (without extension). A one-shot naming a file that is not there fails silently — it logs a warning and plays nothing.

Varying pitch slightly per trigger (say 0.9–1.1) is the cheapest way to stop a repeated one-shot sounding like a repeated one-shot.

Spatial one-shot tracks attenuate to silence at 25 m. An event further away than that must use non-spatial play_sound with a hand-scaled volume, or it will simply not be heard.

Animation API

Animation functions write directly to the animator component on the target entity. The AnimationSync system picks up changes on the next frame:

FunctionDescription
play_clip(entity_id, clip_name)Start playing a named animation clip
stop_clip(entity_id)Stop the current animation
blend_to(entity_id, clip, duration)Crossfade to another clip over the given duration
set_anim_speed(entity_id, speed)Set animation playback speed
set_anim_layer(entity_id, index, clip, weight)Play clip on layer index (additive, unmasked) at weight
set_anim_layer_ex(entity_id, index, clip, weight, mode, mask)Same, with "additive"/"override" and a root-joint mask
set_anim_layer_weight(entity_id, index, weight)Set a layer’s weight instantly (cancels a fade)
fade_anim_layer(entity_id, index, weight, seconds)Ramp a layer’s weight over seconds (engine writes the ramp back each frame)
play_sequence(entity_id, name)Play a *.sequence.toml (timestamped blend/layer/speed/cue events) on this animator
stop_sequence(entity_id)Stop the active sequence
clear_anim_layer(entity_id, index)Deactivate a layer (slot kept so indices stay stable)

Weights are floats — write 0.5, never 0 (Rhai does not coerce ints).

Coordinate System

Flint uses a Y-up, right-handed coordinate system:

  • Forward = -Z (into the screen)
  • Right = +X
  • Up = +Y

Euler angles are stored as (pitch, yaw, roll) in degrees, applied in ZYX order. Positive yaw rotates counter-clockwise when viewed from above (i.e., turns left).

Use the direction helpers (forward_from_yaw, right_from_yaw) to convert a yaw angle into a world-space direction vector. These encode the coordinate convention so scripts don’t need to compute the trig manually.

Math API

FunctionReturnsDescription
PI()f64The constant π (3.14159…)
TAU()f64The constant τ = 2π (6.28318…)
deg_to_rad(degrees)f64Convert degrees to radians
rad_to_deg(radians)f64Convert radians to degrees
forward_from_yaw(yaw_deg)MapForward direction vector #{x, y, z} for a given yaw in degrees
right_from_yaw(yaw_deg)MapRight direction vector #{x, y, z} for a given yaw in degrees
wrap_angle(degrees)f64Normalize an angle to [0, 360)
clamp(val, min, max)f64Clamp a value to a range
lerp(a, b, t)f64Linear interpolation between a and b
random()f64Random value in [0, 1)
random_range(min, max)f64Random value in [min, max)
sin(x)f64Sine (radians)
cos(x)f64Cosine (radians)
abs(x)f64Absolute value
sqrt(x)f64Square root
floor(x)f64Floor
ceil(x)f64Ceiling
min(a, b)f64Minimum of two values
max(a, b)f64Maximum of two values
atan2(y, x)f64Two-argument arctangent (radians)

Ocean API

Available when the scene has an ocean component. All coordinates are world-space; all queries are evaluated on the same clock the renderer used this frame, so what you sample is what is on screen.

FunctionReturnsDescription
ocean_height(x, z)f64Eulerian surface height in meters
ocean_velocity_y(x, z)f64Vertical surface velocity in m/s (analytic ∂h/∂t)
ocean_normal(x, z)MapSurface normal #{x, y, z}
// Float a hull on five probe points.
let p = get_field(me, "transform", "position");
let h = ocean_height(p.x, p.z);

ocean_velocity_y is the impact signal: the relative approach speed between water and hull is what distinguishes a lap from a slam. Without a scene ocean these return 0 (and #{0,1,0}) rather than failing.

A handful of probes per frame is cheap. Thousands are not.

Input and Cursor API

FunctionReturnsDescription
any_input_just_pressed()boolTrue on the frame any key, mouse button, or gamepad button was pressed
set_cursor_captured(captured)Capture (hide + lock) or release the mouse cursor

any_input_just_pressed reads raw presses and bypasses action maps entirely — it is for “press any key to continue”, where the whole point is that you do not care which key.

set_cursor_captured(true) is how a scene gets mouse-look without a character-controller player entity. The engine only captures automatically for scenes that have one, so a fixed-camera or custom-camera scene must ask.

Event API

FunctionDescription
fire_event(name)Fire a named game event
fire_event_data(name, data)Fire an event with a data map payload

Log API

FunctionDescription
log(msg)Log an info-level message
log_info(msg)Alias for log()
log_warn(msg)Log a warning
log_error(msg)Log an error

Physics API

Physics functions provide raycasting and camera access for combat, line-of-sight checks, and interaction targeting:

FunctionReturnsDescription
raycast(ox, oy, oz, dx, dy, dz, max_dist)Map or ()Cast a ray from origin in direction. Returns hit info or () if nothing hit
move_character(id, dx, dy, dz)Map or ()Collision-corrected kinematic movement. Returns #{x, y, z, grounded}
get_collider_extents(id)Map or ()Collider shape dimensions (see below)
get_camera_position()MapCamera world position as #{x, y, z}
get_camera_direction()MapCamera forward vector as #{x, y, z}
set_camera_position(x, y, z)Override camera position from script
set_camera_target(x, y, z)Override camera look-at target from script
set_camera_fov(fov)Override camera field of view (degrees) from script
set_camera_orthographic(enabled)Switch the camera between orthographic and perspective projection
set_camera_ortho_height(height)Orthographic half-height in world units
set_camera_roll(radians)Roll the camera about its view axis (ADR 0022, camera roll override)

The raycast() function automatically excludes the calling entity’s collider from results. On a hit, it returns a map with these fields:

FieldTypeDescription
entityi64Entity ID of the hit object
distancef64Distance from origin to hit point
point_x, point_y, point_zf64World-space hit position
normal_x, normal_y, normal_zf64Surface normal at hit point

move_character performs collision-corrected kinematic movement using Rapier’s shape-sweep. The entity must have rigidbody and collider components. The returned map contains the corrected position and a grounded flag:

#![allow(unused)]
fn main() {
fn on_update() {
    let me = self_entity();
    let dt = delta_time();
    let result = move_character(me, 0.0, -9.81 * dt, 5.0 * dt);
    if result != () {
        set_position(me, result.x, result.y, result.z);
        if result.grounded {
            // Can jump
        }
    }
}
}

get_collider_extents returns the collider shape dimensions. The returned map varies by shape:

  • Box: #{shape: "box", half_x, half_y, half_z}
  • Capsule: #{shape: "capsule", radius, half_height}
  • Sphere: #{shape: "sphere", radius}

Returns () if the entity has no collider.

Example: Hitscan weapon

#![allow(unused)]
fn main() {
fn fire_weapon() {
    let cam_pos = get_camera_position();
    let cam_dir = get_camera_direction();
    let hit = raycast(cam_pos.x, cam_pos.y, cam_pos.z,
                      cam_dir.x, cam_dir.y, cam_dir.z, 100.0);
    if hit != () {
        let target = hit.entity;
        if has_component(target, "health") {
            let hp = get_field(target, "health", "current_hp");
            set_field(target, "health", "current_hp", hp - 25);
        }
    }
}
}

2D Physics

For sprite games the physics world is a flat plane. These mirror the 3D calls above:

FunctionReturnsDescription
set_velocity_2d(id, vx, vy)Set a 2D body’s velocity (deferred command, applied after the script batch)
get_velocity_2d(id)Map or ()Current velocity as #{vx, vy}, or () if the entity has no 2D body
overlap_rect(x, y, w, h)ArrayIDs of every entity whose collider overlaps the rectangle
raycast_2d(ox, oy, dx, dy, max_dist)Map or ()#{entity, distance, point_x, point_y, normal_x, normal_y} or ()

2D Camera

A follow camera with deadzone and smoothing, plus a stackable shake. All positions are world units on the sprite plane; the camera sits at z = 10 looking down -z so every sprite layer stays in front of it.

FunctionReturnsDescription
camera_follow(id, offset_x, offset_y, speed, deadzone_w, deadzone_h)Track an entity each frame: the camera only moves when the target leaves the deadzone rectangle, then eases toward it with frame-rate-independent smoothing at speed. Applies any active shake
camera_follow_position()MapCurrent smoothed follow position as #{x, y}
camera_follow_set(x, y)Teleport the follow position (use on scene enter or respawn to avoid a long ease)
camera_shake(amplitude, frequency, decay)Start or stack a shake. Amplitude takes the max of current and new; frequency in Hz; amplitude decays exponentially at decay per second
camera_shake_stop()Cancel the shake immediately
camera_apply_shake()Advance and apply the shake to a camera you position yourself with set_camera_position (not needed when camera_follow is in use)

Chunks

Large 2D worlds stream in .chunk.toml files at runtime:

FunctionReturnsDescription
load_chunk(path, offset_x, offset_y, chunk_id)Load a chunk file, translating its entities by the offset, under a name you choose
unload_chunk(chunk_id)Despawn every entity that chunk loaded
is_chunk_loaded(chunk_id)boolWhether that chunk is currently resident

Spline API

Query spline entities for path-following, track layouts, and procedural placement:

FunctionReturnsDescription
spline_closest_point(spline_id, x, y, z)Map or ()Nearest point on spline to query position. Returns #{t, x, y, z, dist_sq}
spline_sample_at(spline_id, t)Map or ()Sample spline at parameter t (0.0–1.0). Returns #{x, y, z, fwd_x, fwd_y, fwd_z, right_x, right_y, right_z}
spline_is_gap(spline_id, t)boolWhether t falls inside one of the spline’s authored gaps (gap_starts / gap_ends in spline_data; ranges may wrap past 1.0)
spline_gap_at(spline_id, t)Map or ()The gap containing t as #{start_t, end_t}, or () if t is on solid track

The t parameter wraps for closed splines. The returned forward and right vectors are normalized and can be used for orientation.

Particle API

FunctionDescription
emit_burst(entity_id, count)Fire N particles immediately
start_emitter(entity_id)Start continuous emission
stop_emitter(entity_id)Stop emission (existing particles finish their lifetime)
set_emission_rate(entity_id, rate)Change emission rate dynamically

See Particles for full component schema and recipes.

Post-Processing API

Control the HDR post-processing pipeline at runtime from scripts:

FunctionDescription
set_vignette(intensity)Set vignette intensity (0.0 = none, 1.0 = heavy)
set_bloom_intensity(intensity)Set bloom strength (0.0 = none)
set_exposure(value)Set exposure multiplier (1.0 = default)
set_chromatic_aberration(amount)Set chromatic aberration strength
set_radial_blur(amount)Set radial blur strength
set_ssao_intensity(value)Set SSAO intensity
set_fog_density(value)Set fog density
set_fog_color(r, g, b)Set fog color (linear 0–1)
set_render_mode(mode, mix)Stylized render mode (see below)
set_render_mode_params(x, y, z, w)Per-mode tuning parameters
set_desaturation(amount)Desaturate toward ash grey (0 = full colour, 1 = grey; ADR 0021)
set_dof(strength)Depth-of-field defocus strength (0 = sharp, 1 = full blur)
set_dof_focus(distance, range)Focus plane distance and half-width, in view metres

These overrides are applied each frame and combine with the scene’s [post_process] baseline settings. Useful for dynamic effects like speed vignetting, boost bloom, or exposure flashes.

All of these are sticky except set_render_mode. Set an override once and it persists until you change it. The render mode is the deliberate exception: it is cleared the frame your script stops calling it, so a crashed or hot-reloaded script cannot strand the world inside an effect. Call it every frame the effect is active. See Post-Processing: Render Modes.

Conducted Parameters API

When a scene carries a music_session component, the player fills a per-frame snapshot of the session’s state and exposes it to every script through these getters (ADR 0020, conducted-parameters script surface). Without a session every getter returns a neutral value: a clean, settled world with coherence and reassembly at 1.0, zero lean, and 1e6 beats until anything upcoming. Bindings written as 1 - conducted_coherence() therefore show nothing when no music is running, and scripts never need to test for a session.

FunctionReturnsDescription
conducted_lean()#{x, y}The player’s lean, both axes in [-1, 1]
conducted_target()#{x, y}The chart’s current lean target
conducted_next_target()#{x, y, beats}The next authored lean key and suite beats until its anchor (ADR 0023). beats = 1e6 and x/y = the current target when nothing is upcoming
conducted_next_pulse()#{beats, open}Suite beats until the next judgment window’s anchor, and whether that window is open right now (a press would land)
conducted_sway()#{x, y}Right-stick sway (zeros under the prototype input map)
conducted_pressure_l() / conducted_pressure_r()f64Trigger depths in [0, 1] (zeros under the prototype map)
conducted_coherence()f64The coherence integrator, 1.0 = fully in step
conducted_beat()f64Suite beats from zero, accumulated across tempo changes
conducted_beat_phase() / conducted_bar_phase()f640..1 within the current beat / bar
conducted_bar()i64Current bar number
conducted_section()StringCurrent section name, "" when none
conducted_pulses()ArrayPulses judged this frame, each #{age, err_ms, kind} with kind one of "hit", "miss", "spurious". Empty most frames
conducted_cues()ArrayChart cues fired this frame, each #{name, age, params} (ADR 0033). params is the cue’s flat table as floats, strings and bools; nested tables are dropped
conducted_desaturate() / conducted_blur() / conducted_chromatic()f64Ladder visual ramps, 0 = clean
conducted_reassembly()f641 in normal play, 0 rising to 1 while re-gathering after a full fail
conducted_rewind()f64Rewind-interlude progress, 0 = not rewinding
conducted_no_input()boolThe session has seen no input since the bar-2 check
conducted_preroll()boolStill in the count-in; the world should stay untouched

All scalars are f64, so compare and multiply with float literals. A typical binding reads the snapshot in on_update and writes a post-process override:

fn on_update() {
    let grey = conducted_desaturate();
    set_desaturation(grey);
    let p = conducted_next_pulse();
    if p.open { set_vignette(0.4); } else { set_vignette(0.2); }
}

Audio Filter API

FunctionDescription
set_audio_lowpass(cutoff_hz)Set master bus low-pass filter cutoff frequency (Hz)

The low-pass filter affects all audio output. Pass 20000.0 for no filtering, lower values for a muffled effect. Useful for speed-dependent audio (e.g., wind rush at high speed) or dramatic transitions.

Scene Transition API

Load new scenes, manage game state, and persist data across transitions:

FunctionReturnsDescription
load_scene(path)Begin transition to a new scene
reload_scene()Reload the current scene
current_scene()StringPath of the current scene
transition_progress()f64Progress of the current transition (0.0–1.0)
transition_phase()StringCurrent transition phase ("idle", "exiting", "loading", "entering")
is_transitioning()boolWhether a scene transition is in progress
complete_transition()Advance to the next transition phase

Scene transitions follow a lifecycle: Idle -> Exiting -> Loading -> Entering -> Idle. During the Exiting and Entering phases, on_draw_ui() still runs so scripts can draw fade effects using transition_progress(). Call complete_transition() to advance phases — this gives scripts full control over transition timing and visuals.

Two additional callbacks fire during transitions:

CallbackSignatureWhen It Fires
on_scene_enterfn on_scene_enter()After a new scene is loaded and ready
on_scene_exitfn on_scene_exit()Before the current scene is unloaded

Game State Machine API

A pushdown automaton for managing game states (playing, paused, custom):

FunctionReturnsDescription
push_state(name)Push a named state onto the stack
pop_state()Pop the top state (returns to previous)
replace_state(name)Replace the top state
current_state()StringName of the current (top) state
state_stack()ArrayAll state names from bottom to top
register_state(name, config)Register a custom state template

Built-in state templates:

  • "playing" — all systems run (default)
  • "paused" — physics, scripts, animation, particles, and audio are paused; rendering runs; on_draw_ui() still fires (for pause menus)
  • "loading" — all systems paused

Persistent Data API

Key-value store that survives scene transitions:

FunctionReturnsDescription
persist_set(key, value)Store a value
persist_get(key)DynamicRetrieve a value (or () if not set)
persist_has(key)boolCheck if a key exists
persist_remove(key)Remove a key
persist_clear()Clear all persistent data
persist_keys()ArrayList all keys
persist_save(path)Save store to a TOML file
persist_load(path)Load store from a TOML file

Data-Driven UI API

Load and manipulate TOML-defined UI documents at runtime:

FunctionReturnsDescription
load_ui(path)i64Load a UI document (.ui.toml). Returns a handle
unload_ui(handle)Unload a UI document
ui_set_text(element_id, text)Set the text content of a UI element
ui_show(element_id)Show a hidden UI element
ui_hide(element_id)Hide a UI element
ui_set_visible(element_id, visible)Set element visibility
ui_set_color(element_id, r, g, b, a)Set element text/foreground color
ui_set_bg_color(element_id, r, g, b, a)Set element background color
ui_set_style(element_id, property, value)Override a single style property
ui_reset_style(element_id)Remove all style overrides
ui_set_class(element_id, class_name)Change an element’s style class
ui_exists(element_id)boolCheck if a UI element exists
ui_get_rect(element_id)MapGet resolved position/size as #{x, y, width, height}

Older scenes place HUD elements as entities with screen_anchor, ui_text and ui_fill components instead of a UI document. Those are driven with entity-level setters:

FunctionDescription
set_text(entity_id, text)Write ui_text.text
set_text_color(entity_id, r, g, b, a)Write ui_text.color
ui_set_value(entity_id, value)Write ui_fill.value (a 0–1 bar fill)
set_anchor(entity_id, anchor)Write screen_anchor.anchor ("top-left" through "bottom-right")
set_anchor_offset(entity_id, x, y)Write screen_anchor.offset_x / offset_y

UI documents are defined with paired .ui.toml (layout) and .style.toml (styling) files, following an HTML/CSS/JS-like separation of concerns. See File Formats for the format specification.

UI Draw API

The draw API lets scripts render 2D overlays each frame via the on_draw_ui() callback. Draw commands are issued in screen-space coordinates (logical points, not physical pixels) and rendered by the engine through egui.

Draw Primitives

FunctionDescription
draw_text(x, y, text, size, r, g, b, a)Draw text at position
draw_text_ex(x, y, text, size, r, g, b, a, layer)Draw text with explicit layer
draw_text_stroked(x, y, text, size, r, g, b, a, stroke_r, stroke_g, stroke_b, stroke_a, stroke_width)Text with an outline stroke behind it
draw_rect(x, y, w, h, r, g, b, a)Draw filled rectangle
draw_rect_ex(x, y, w, h, r, g, b, a, rounding, layer)Filled rectangle with corner rounding and layer
draw_rect_outline(x, y, w, h, r, g, b, a, thickness)Rectangle outline
draw_circle(x, y, radius, r, g, b, a)Draw filled circle
draw_circle_ex(x, y, radius, r, g, b, a, layer)Filled circle with explicit layer
draw_circle_outline(x, y, radius, r, g, b, a, thickness)Circle outline
draw_circle_outline_ex(x, y, radius, r, g, b, a, thickness, layer)Circle outline with explicit layer
draw_line(x1, y1, x2, y2, r, g, b, a, thickness)Draw a line segment
draw_line_ex(x1, y1, x2, y2, r, g, b, a, thickness, layer)Line segment with explicit layer
draw_sprite(x, y, w, h, name)Draw a sprite image
draw_sprite_ex(x, y, w, h, name, u0, v0, u1, v1, r, g, b, a, layer)Sprite with custom UV coordinates, tint, and layer

Query Functions

FunctionReturnsDescription
screen_width()f64Logical screen width in points
screen_height()f64Logical screen height in points
measure_text(text, size)MapApproximate text size as #{width, height}
find_nearest_interactable()Map or ()Nearest interactable entity info, or () if none in range

find_nearest_interactable() returns a map with entity (ID), prompt_text, interaction_type, and distance fields when an interactable entity is within range.

Layer Ordering

All _ex draw variants accept a layer parameter which must be an integer (0, 1, -1), not a float. Using 0.0 instead of 0 will cause a “Function not found” error because Rhai does not implicitly convert between float and int. Commands are sorted by layer before rendering:

  • Negative layers render behind (background elements)
  • Layer 0 is the default
  • Positive layers render in front (foreground elements)

Coordinate System

Coordinates are in egui logical points, not physical pixels. On high-DPI displays, logical points differ from pixels by the scale factor. Use screen_width() and screen_height() for layout calculations — they return the correct logical dimensions.

Sprite Loading

Sprite names map to image files in the sprites/ directory (without extension). Supported formats: PNG, JPG, BMP, TGA. Textures are lazy-loaded on first use and cached for subsequent frames.

Sprite API

Runtime control of the sprite component on 2D entities (see 2D Sprites for the component and clip formats):

FunctionDescription
set_sprite_source_rect(entity_id, x, y, w, h)Source rectangle in texture pixels (manual atlas frames)
set_sprite_flip(entity_id, flip_x, flip_y)Mirror horizontally / vertically
set_sprite_tint(entity_id, r, g, b, a)Multiply colour
set_sprite_visible(entity_id, visible)Show or hide without removing the component
set_sprite_layer(entity_id, layer) / get_sprite_layer(entity_id)Draw-order layer (integer; higher draws in front)

Data-Driven UI System

For structured interfaces like menus, HUDs, and dialog boxes, Flint provides a data-driven UI system that separates layout, style, and logic into distinct files. The procedural draw_* API above continues to work alongside it for dynamic elements like minimaps or particle trails.

The pattern is:

  • Layout (.ui.toml) — element tree with types, hierarchy, anchoring, and default text/images
  • Style (.style.toml) — named style classes with visual properties (colors, sizes, fonts, padding)
  • Logic (.rhai) — scripts load UI documents and manipulate elements at runtime

File Format: .ui.toml

[ui]
name = "Race HUD"
style = "ui/race_hud.style.toml"   # Path to companion style file

[elements.speed_panel]
type = "panel"
anchor = "bottom-center"
class = "hud-panel"

[elements.speed_label]
type = "text"
parent = "speed_panel"
class = "speed-text"
text = "0"

[elements.lap_counter]
type = "text"
anchor = "top-right"
class = "lap-text"
text = "Lap 1/3"
FieldTypeDefaultDescription
typestring"panel"Element type: panel, text, rect, circle, image
anchorstring"top-left"Screen anchor for root elements (see below)
parentstringParent element ID (child inherits position from parent)
classstring""Style class name from the companion .style.toml
textstring""Default text content (for text elements)
srcstring""Image source path (for image elements)
visiblebooltrueInitial visibility

Anchor points: top-left, top-center, top-right, center-left, center, center-right, bottom-left, bottom-center, bottom-right

File Format: .style.toml

[styles.hud-panel]
width = 200
height = 60
bg_color = [0.0, 0.0, 0.0, 0.6]
rounding = 8
padding = [12, 8, 12, 8]
layout = "stack"

[styles.speed-text]
font_size = 32
color = [1.0, 1.0, 1.0, 1.0]
text_align = "center"
width_pct = 100

[styles.lap-text]
font_size = 24
color = [1.0, 0.85, 0.2, 1.0]
width = 120
height = 30
x = -10
y = 10

Style properties:

PropertyTypeDefaultDescription
x, yfloat0Offset from anchor point or parent
width, heightfloat0Fixed dimensions in logical points
width_pct, height_pctfloatPercentage of parent width/height (0–100)
height_autoboolfalseAuto-size height from children extent
color[r,g,b,a][1,1,1,1]Primary color (text color, shape fill)
bg_color[r,g,b,a][0,0,0,0]Background color (panels)
font_sizefloat16Text font size
text_alignstring"left"Text alignment: left, center, right
roundingfloat0Corner rounding for panels/rects
opacityfloat1.0Element opacity multiplier
thicknessfloat1Stroke thickness for outlines
radiusfloat0Circle radius
layerint0Render layer (negative = behind, positive = in front)
padding[l,t,r,b][0,0,0,0]Interior padding (left, top, right, bottom)
layoutstring"stack"Child flow: stack (vertical) or horizontal
margin_bottomfloat0Space below element in flow layout

Rhai API: Data-Driven UI

FunctionReturnsDescription
load_ui(layout_path)i64Load a .ui.toml document. Returns a handle (-1 on error)
unload_ui(handle)Unload a previously loaded UI document
ui_set_text(element_id, text)Change an element’s text content
ui_show(element_id)Show an element
ui_hide(element_id)Hide an element
ui_set_visible(element_id, visible)Set element visibility
ui_set_color(element_id, r, g, b, a)Override primary color
ui_set_bg_color(element_id, r, g, b, a)Override background color
ui_set_style(element_id, prop, value)Override any style property by name
ui_reset_style(element_id)Clear all runtime overrides
ui_set_class(element_id, class)Switch an element’s style class
ui_exists(element_id)boolCheck if an element exists in any loaded document
ui_get_rect(element_id)Map or ()Get resolved screen rect as #{x, y, w, h}

Element IDs are the TOML key names from the layout file (e.g., "speed_label", "lap_counter"). Functions search all loaded documents when resolving an element ID.

Example: Menu with Data-Driven UI

# ui/main_menu.ui.toml
[ui]
name = "Main Menu"
style = "ui/main_menu.style.toml"

[elements.title]
type = "text"
anchor = "top-center"
class = "title"
text = "MY GAME"

[elements.menu_panel]
type = "panel"
anchor = "center"
class = "menu-container"

[elements.btn_play]
type = "text"
parent = "menu_panel"
class = "menu-item"
text = "Play"

[elements.btn_quit]
type = "text"
parent = "menu_panel"
class = "menu-item"
text = "Quit"
#![allow(unused)]
fn main() {
// scripts/menu.rhai
let menu_handle = 0;
let selected = 0;

fn on_init() {
    menu_handle = load_ui("ui/main_menu.ui.toml");
}

fn on_update() {
    // Highlight selected item
    if selected == 0 {
        ui_set_color("btn_play", 1.0, 0.85, 0.2, 1.0);
        ui_set_color("btn_quit", 0.6, 0.6, 0.6, 1.0);
    } else {
        ui_set_color("btn_play", 0.6, 0.6, 0.6, 1.0);
        ui_set_color("btn_quit", 1.0, 0.85, 0.2, 1.0);
    }

    if is_action_just_pressed("move_forward") { selected = 0; }
    if is_action_just_pressed("move_backward") { selected = 1; }

    if is_action_just_pressed("interact") {
        if selected == 0 { load_scene("scenes/level1.scene.toml"); }
    }
}
}

When to Use Each UI Approach

ApproachBest For
Data-driven (.ui.toml + .style.toml)Menus, HUD panels, dialog boxes, score displays — anything with stable structure
Procedural (draw_* API)Crosshairs, damage flashes, debug overlays, dynamic effects — anything computed per-frame
Both togetherLoad a HUD layout for structure, use draw_* for dynamic overlays on top

Hot-Reload

The script system checks file modification timestamps each frame. When a .rhai file changes on disk:

  1. The file is recompiled to a new AST
  2. If compilation succeeds, the old AST is replaced and the new version takes effect immediately
  3. If compilation fails, the old AST is kept and an error is logged — the game never crashes from a script typo

This enables a fast iteration workflow: edit a script in your text editor, save, and see the result in the running game without restarting.

Interactable System

The interactable component marks entities that the player can interact with at close range. It works together with scripting to create interactive objects:

[entities.tavern_door]
archetype = "door"

[entities.tavern_door.interactable]
prompt_text = "Open Door"
range = 3.0
interaction_type = "use"
enabled = true

[entities.tavern_door.script]
source = "door_interact.rhai"
FieldTypeDefaultDescription
prompt_textstring"Interact"Text shown on the HUD when in range
rangef323.0Maximum interaction distance from the player
interaction_typestring"use"Type of interaction: use, talk, examine
enabledbooltrueWhether this interactable is currently active

When the player is within range of an enabled interactable entity, the HUD displays a crosshair and the prompt_text. Pressing the Interact key (E) fires the on_interact callback on the entity’s script.

The find_nearest_interactable() function scans all interactable entities each frame to determine which (if any) to highlight. The HUD prompt fades in and out based on proximity.

Example: Interactive Door

#![allow(unused)]
fn main() {
// scripts/door_interact.rhai

let door_open = false;

fn on_interact() {
    let me = self_entity();
    door_open = !door_open;

    if door_open {
        play_clip(me, "door_swing");
        play_sound("door_open");
        log("Door opened");
    } else {
        play_clip(me, "door_close");
        play_sound("door_close");
        log("Door closed");
    }
}
}

Example: Flickering Torch

#![allow(unused)]
fn main() {
// scripts/torch_flicker.rhai

fn on_update() {
    let me = self_entity();
    let t = total_time();

    // Flicker the emissive intensity with layered sine waves
    let flicker = 0.8 + 0.2 * sin(t * 8.0) * sin(t * 13.0 + 0.7);
    set_field(me, "material", "emissive_strength", clamp(flicker, 0.3, 1.0));
}
}

Example: NPC Bartender

#![allow(unused)]
fn main() {
// scripts/bartender.rhai

fn on_init() {
    let me = self_entity();
    play_clip(me, "idle");
    log("Bartender ready to serve");
}

fn on_interact() {
    let me = self_entity();
    let player = get_entity("player");
    let dist = distance(me, player);

    // Face the player
    let my_pos = get_position(me);
    let player_pos = get_position(player);
    let angle = atan2(player_pos.x - my_pos.x, player_pos.z - my_pos.z);
    set_rotation(me, 0.0, angle * 57.2958, 0.0);

    // React
    play_sound("glass_clink");
    blend_to(me, "wave", 0.3);
    log("Bartender waves at you");
}
}

Architecture

on_init ──► ScriptEngine.call_inits()
                │
                ▼
            per-entity Scope + AST
                │
                ▼
on_update ──► ScriptEngine.call_updates()
                │
                ▼
events ────► ScriptEngine.process_events()
                │                    │
                ▼                    ▼
        ECS reads/writes      ScriptCommands
        (via ScriptCallContext)  (PlaySound, FireEvent, Log,
                                  LoadScene, LoadChunk, SetVelocity2D, ...)
                                     │
on_draw_ui ► ScriptEngine            ▼
                │              PlayerApp processes
                ▼              deferred commands
          DrawCommands
          (Text, Rect, Circle,
           Line, Sprite)
                │
                ▼
          egui layer_painter()
          renders 2D overlay

Each entity gets its own Rhai Scope, preserving persistent variables between frames. The Engine is shared across all entities. World access happens through a ScriptCallContext that holds a raw pointer to the FlintWorld — valid only during the call batch, cleared immediately after.

The context also carries the per-frame inputs the host fills before each batch: the InputState snapshot (actions, mouse, touch, swipes), the ConductedSnapshot from the music session (neutral when there is none), the set of loaded chunk IDs, and the camera follow / shake state. Setters such as set_vignette or set_camera_roll write Option overrides on the context that the player applies after the batch; ScriptCommand is the deferred-effect channel for anything that needs the world mutably (spawning, scene loads, chunk loads, 2D velocity). Chart cue parameters cross the boundary as a small CueParam enum (Number, Text, Flag) so flint-script never depends on flint-music.

Example: Combat HUD

For game-specific UI, use a dedicated hud_controller entity with a script component. The entity has no physical presence in the world — it exists only to run the HUD script:

[entities.hud_controller]

[entities.hud_controller.script]
source = "hud.rhai"
#![allow(unused)]
fn main() {
// scripts/hud.rhai

fn on_draw_ui() {
    let sw = screen_width();
    let sh = screen_height();

    // Crosshair
    let cx = sw / 2.0;
    let cy = sh / 2.0;
    draw_line(cx - 10.0, cy, cx + 10.0, cy, 0.0, 1.0, 0.0, 0.8, 2.0);
    draw_line(cx, cy - 10.0, cx, cy + 10.0, 0.0, 1.0, 0.0, 0.8, 2.0);

    // Health bar
    let player = get_entity("player");
    if player != -1 && has_component(player, "health") {
        let hp = get_field(player, "health", "current_hp");
        let max_hp = get_field(player, "health", "max_hp");
        let pct = hp / max_hp;

        draw_rect(20.0, sh - 40.0, 200.0, 20.0, 0.2, 0.2, 0.2, 0.8);
        draw_rect(20.0, sh - 40.0, 200.0 * pct, 20.0, 0.8, 0.1, 0.1, 0.9);
        draw_text(25.0, sh - 38.0, `HP: ${hp}/${max_hp}`, 14.0, 1.0, 1.0, 1.0, 1.0);
    }

    // Interaction prompt
    let interact = find_nearest_interactable();
    if interact != () {
        let prompt = interact.prompt_text;
        let tw = measure_text(prompt, 18.0);
        draw_text(cx - tw.width / 2.0, cy + 40.0, `[E] ${prompt}`, 18.0, 1.0, 1.0, 1.0, 0.9);
    }
}
}

This pattern keeps all game-specific HUD logic in scripts rather than engine code. The engine provides only the generic draw primitives.

Further Reading